diff --git a/DashWallet.xcodeproj/project.pbxproj b/DashWallet.xcodeproj/project.pbxproj index ddd1bb292..207ae7895 100644 --- a/DashWallet.xcodeproj/project.pbxproj +++ b/DashWallet.xcodeproj/project.pbxproj @@ -1900,6 +1900,10 @@ RC00A0012FA0000000000001 /* TopIntro.swift in Sources */ = {isa = PBXBuildFile; fileRef = RC00A0032FA0000000000001 /* TopIntro.swift */; }; RC00A0022FA0000000000001 /* TopIntro.swift in Sources */ = {isa = PBXBuildFile; fileRef = RC00A0032FA0000000000001 /* TopIntro.swift */; }; RC00A0042FA0000000000001 /* ExploreMenuScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = RC00A0062FA0000000000001 /* ExploreMenuScreen.swift */; }; + UM00A0012FA0000000000001 /* UsernameMarketplaceScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = UM00A0032FA0000000000001 /* UsernameMarketplaceScreen.swift */; }; + UM00A0022FA0000000000001 /* UsernameMarketplaceScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = UM00A0032FA0000000000001 /* UsernameMarketplaceScreen.swift */; }; + UM00B0012FA0000000000001 /* UsernameMarketplaceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = UM00B0032FA0000000000001 /* UsernameMarketplaceService.swift */; }; + UM00B0022FA0000000000001 /* UsernameMarketplaceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = UM00B0032FA0000000000001 /* UsernameMarketplaceService.swift */; }; RC00A0052FA0000000000001 /* ExploreMenuScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = RC00A0062FA0000000000001 /* ExploreMenuScreen.swift */; }; RC00C0012FA0000000000001 /* CSVExportSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = RC00C0032FA0000000000001 /* CSVExportSheet.swift */; }; RC00C0022FA0000000000001 /* CSVExportSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = RC00C0032FA0000000000001 /* CSVExportSheet.swift */; }; @@ -3640,6 +3644,8 @@ GG0000032DUMMYID001234567 /* GeoRestrictionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoRestrictionService.swift; sourceTree = ""; }; RC00A0032FA0000000000001 /* TopIntro.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopIntro.swift; sourceTree = ""; }; RC00A0062FA0000000000001 /* ExploreMenuScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreMenuScreen.swift; sourceTree = ""; }; + UM00A0032FA0000000000001 /* UsernameMarketplaceScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsernameMarketplaceScreen.swift; sourceTree = ""; }; + UM00B0032FA0000000000001 /* UsernameMarketplaceService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsernameMarketplaceService.swift; sourceTree = ""; }; RC00C0032FA0000000000001 /* CSVExportSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSVExportSheet.swift; sourceTree = ""; }; FC1426BD13E794C788646055 /* ContestedNamesService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ContestedNamesService.swift; sourceTree = ""; }; 15D89828EC913714251D0B58 /* MasternodeVoterRegistry.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MasternodeVoterRegistry.swift; sourceTree = ""; }; @@ -6261,6 +6267,7 @@ 47AE8BDF28C1305E00490F5E /* Views */, 7569D6822DE6DA6800768BFF /* ExploreViewController.swift */, RC00A0062FA0000000000001 /* ExploreMenuScreen.swift */, + UM00A0032FA0000000000001 /* UsernameMarketplaceScreen.swift */, ); path = "Explore Dash"; sourceTree = ""; @@ -7115,6 +7122,7 @@ E181EB71C8803101EA789AA4 /* SwiftDashSDKHost.swift */, 35F37C177039C76661FA2DB2 /* SwiftDashSDKSPVCoordinator.swift */, E6E56673B2BFED8EC0181A1F /* PlatformAddressSyncCoordinator.swift */, + UM00B0032FA0000000000001 /* UsernameMarketplaceService.swift */, A1FA10C00000000000000001 /* AssetLockRecoveryService.swift */, 51AA000D2F97000D005A000D /* ShieldedSyncMonitor.swift */, 43C830DD6D63115525925F75 /* PlatformCreditsFormatter.swift */, @@ -9827,6 +9835,8 @@ 75EBAA0F2BB99036004488E3 /* TextIntro.swift in Sources */, RC00A0012FA0000000000001 /* TopIntro.swift in Sources */, RC00A0042FA0000000000001 /* ExploreMenuScreen.swift in Sources */, + UM00A0012FA0000000000001 /* UsernameMarketplaceScreen.swift in Sources */, + UM00B0012FA0000000000001 /* UsernameMarketplaceService.swift in Sources */, 0F71317728F436920072F454 /* ServiceOverviewTableCell.swift in Sources */, 47838B87290670630003E8AB /* IntegrationViewController.swift in Sources */, 758A8F172C4D35A80012FA41 /* NSLayoutConstraint+DashWallet.swift in Sources */, @@ -10543,6 +10553,8 @@ 75EBAA102BB99036004488E3 /* TextIntro.swift in Sources */, RC00A0022FA0000000000001 /* TopIntro.swift in Sources */, RC00A0052FA0000000000001 /* ExploreMenuScreen.swift in Sources */, + UM00A0022FA0000000000001 /* UsernameMarketplaceScreen.swift in Sources */, + UM00B0022FA0000000000001 /* UsernameMarketplaceService.swift in Sources */, 757514E52B1735370026AD8E /* JoinDashPayView.swift in Sources */, C9D2C73C2A320AA000D15901 /* DWDecimalInputValidator.m in Sources */, C9D2C73D2A320AA000D15901 /* PaymentMethods.swift in Sources */, diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWContestedNameStatusService.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWContestedNameStatusService.swift index 04a887df7..d2d0e1975 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWContestedNameStatusService.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/Identity/DWContestedNameStatusService.swift @@ -213,8 +213,15 @@ public final class DWContestedNameStatusService: NSObject { @nonobjc func finalizeWon(username: String, network: Network) { - DWGlobalOptions.sharedInstance().dashpayUsername = username - DWGlobalOptions.sharedInstance().dashpayRegistrationCompleted = true + // Backfill the global username mirror only when it's empty (the + // setup-flow first-username case). A contested win on a SECOND + // name β€” requested from the username marketplace β€” must not + // displace the username the user already shows everywhere. + let options = DWGlobalOptions.sharedInstance() + if options.dashpayUsername?.isEmpty != false { + options.dashpayUsername = username + } + options.dashpayRegistrationCompleted = true clearPending(for: network) Self.logger.info("πŸͺͺ CONTEST-SVC :: finalizeWon label=\(username, privacy: .public)") DWCurrentUserIdentityInfo.shared.refreshFromSDK() diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift index 3021bc65b..404adf467 100644 --- a/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/PlatformAddressSyncCoordinator.swift @@ -813,6 +813,21 @@ public final class PlatformAddressSyncCoordinator: NSObject, ObservableObject { } catch { Self.logger.error("πŸ‘₯ DASHPAY-SYNC :: startDashPaySync failed: \(String(describing: error), privacy: .public)") } + + // Start the recurring DPNS username-marketplace sync (tracks the + // wallet identities' names with sale state, detects sold/ + // transferred departures, refreshes seller balances). Same + // best-effort contract as the blocks above; the marketplace + // screen's pull-to-refresh (`syncDpnsMarketplace`) still works + // without the loop. + do { + if try !manager.isDpnsSyncRunning() { + try manager.startDpnsSync() + } + Self.logger.info("🏷️ DPNS-SYNC :: started for \(network.rawValue, privacy: .public)") + } catch { + Self.logger.error("🏷️ DPNS-SYNC :: startDpnsSync failed: \(String(describing: error), privacy: .public)") + } #endif self.sdk = SwiftDashSDKHost.shared.sdk diff --git a/DashWallet/Sources/Infrastructure/SwiftDashSDK/UsernameMarketplaceService.swift b/DashWallet/Sources/Infrastructure/SwiftDashSDK/UsernameMarketplaceService.swift new file mode 100644 index 000000000..94ead38be --- /dev/null +++ b/DashWallet/Sources/Infrastructure/SwiftDashSDK/UsernameMarketplaceService.swift @@ -0,0 +1,404 @@ +// +// UsernameMarketplaceService.swift +// DashWallet +// +// DPNS username marketplace over the wallet-level SDK surface that +// landed with platform #4348 (`DpnsMarketplace.swift`): typed search +// with sale state, locally persisted my-names rows (including retained +// sold/transferred departures), per-name trade history from the +// Document History contract, and orchestrated trade operations with +// typed errors β€” `notForSale`, `priceChanged` (a purchase never +// executes at a price the user didn't confirm; consensus is the +// backstop), `insufficientIdentityCredits`, and +// `contestedNameNotTradable`. +// +// This layer adds only what belongs to the app: the PIN/biometric gate +// before anything is signed, resolution of the wallet's main identity, +// friendlier wording for the errors users act on, and the contested +// guard for direct registration (the trade ops are typed by the SDK). +// +// dashpay target only. +// + +import Foundation +import OSLog +import SwiftDashSDK +import SwiftData + +// MARK: - Identifiable projections for SwiftUI + +extension DpnsMarketplaceName: Identifiable { + public var id: Data { documentId } + + var priceDuffs: UInt64? { priceCredits.map { $0 / 1_000 } } + var isForSale: Bool { priceCredits != nil } + + func isOwned(by identityId: Data?) -> Bool { + guard let identityId else { return false } + return ownerId == identityId + } +} + +extension DpnsNameStateRow: Identifiable { + public var id: Data { documentId } + + var priceDuffs: UInt64? { priceCredits.map { $0 / 1_000 } } + var isForSale: Bool { + guard case .owned = status else { return false } + return priceCredits != nil + } +} + +// MARK: - UsernameMarketplaceService + +/// Stateless facade over the SDK marketplace surface. Instantiated per +/// view model (it holds only an authorizer); deliberately NOT a +/// singleton. +@MainActor +struct UsernameMarketplaceService { + + private static let logger = Logger( + subsystem: "org.dashfoundation.dash", + category: "swift-sdk-migration.username-marketplace") + + enum ServiceError: LocalizedError { + case noIdentity + case contestedName + case invalidRecipient + case invalidPrice + case authCancelled + case authFailed + case contestInProgress(pendingLabel: String) + + var errorDescription: String? { + switch self { + case .noIdentity: + return NSLocalizedString("No DashPay identity is registered", comment: "DashPay") + case .contestedName: + 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: + return NSLocalizedString("Authentication cancelled", comment: "DashPay") + case .authFailed: + return NSLocalizedString("Authentication failed", comment: "DashPay") + } + } + } + + private let authorizer = DWIdentityAuthorizer() + + // MARK: Reads + + /// Prefix search with live sale state β€” one network query. + func search(prefix: String, limit: UInt32 = 25) async throws -> [DpnsMarketplaceName] { + guard let wallet = SwiftDashSDKHost.shared.wallet else { return [] } + return try await wallet.searchDpnsMarketplace(prefix: prefix, limit: limit) + } + + /// The main identity's tracked names from the wallet's local rows β€” + /// no network round-trip. Includes retained `.sold` / `.transferred` + /// departures so the UI can show what left and to whom. + func myNames() async throws -> [DpnsNameStateRow] { + guard let wallet = SwiftDashSDKHost.shared.wallet, + let identityId = DWCurrentUserIdentityInfo.shared.identityId else { return [] } + return try await wallet.myDpnsMarketplaceNames(identityId: identityId) + } + + /// Authoritative live state of one exact name; nil = unregistered. + func nameState(_ name: String) async throws -> DpnsMarketplaceName? { + guard let wallet = SwiftDashSDKHost.shared.wallet else { return nil } + return try await wallet.dpnsMarketplaceNameState(name: name) + } + + /// Full trade timeline (registration, listings, purchases with + /// counterparties, transfers), block-time ascending. + func history(_ name: String) async throws -> [DpnsNameHistoryEvent] { + guard let wallet = SwiftDashSDKHost.shared.wallet else { return [] } + return try await wallet.dpnsNameHistory(name: name) + } + + /// Pull-to-refresh: one marketplace sync pass (tracked names, added/ + /// departed labels, price changes, seller balance refreshes). + @discardableResult + func syncNow() async throws -> DpnsMarketplaceSyncSummary { + guard let wallet = SwiftDashSDKHost.shared.wallet else { + throw ServiceError.noIdentity + } + return try await wallet.syncDpnsMarketplace() + } + + // MARK: Trade actions (all PIN-gated; SDK errors are typed) + + func setPrice(name: String, priceDuffs: UInt64) async throws { + let (wallet, container, ownerId) = try requireOwnContext() + guard priceDuffs <= UInt64.max / 1_000 else { + throw ServiceError.invalidPrice + } + try await authorize() + _ = try await wallet.setDpnsNamePrice( + ownerIdentityId: ownerId, + name: name, + priceCredits: priceDuffs * 1_000, + signer: KeychainSigner(modelContainer: container)) + Self.logger.info("🏷️ MARKET :: price set on \(name, privacy: .public)") + } + + func removeFromSale(name: String) async throws { + let (wallet, container, ownerId) = try requireOwnContext() + try await authorize() + _ = try await wallet.delistDpnsName( + ownerIdentityId: ownerId, + name: name, + signer: KeychainSigner(modelContainer: container)) + Self.logger.info("🏷️ MARKET :: delisted \(name, privacy: .public)") + } + + func transfer(name: String, toIdentityBase58 recipient: String) async throws { + let (wallet, container, ownerId) = try requireOwnContext() + guard let recipientId = Data.identifier(fromBase58: recipient), recipientId.count == 32 else { + throw ServiceError.invalidRecipient + } + try await authorize() + _ = try await wallet.transferDpnsName( + ownerIdentityId: ownerId, + name: name, + recipientId: recipientId, + signer: KeychainSigner(modelContainer: container)) + Self.logger.info("🏷️ MARKET :: transferred \(name, privacy: .public)") + DWCurrentUserIdentityInfo.shared.refreshFromSDK() + } + + /// Buy a listed name at exactly `expectedPriceCredits` β€” the SDK + /// pre-flights the listing and the buyer's identity balance, pins the + /// confirmed price into the transition, and consensus rejects any + /// seller-side change (typed `.priceChanged`). + func purchase(name: String, expectedPriceCredits: UInt64) async throws { + let (wallet, container, buyerId) = try requireOwnContext() + try await authorize() + _ = try await wallet.purchaseDpnsName( + purchaserIdentityId: buyerId, + name: name, + expectedPriceCredits: expectedPriceCredits, + signer: KeychainSigner(modelContainer: container)) + Self.logger.info("🏷️ MARKET :: purchased \(name, privacy: .public) for \(expectedPriceCredits, privacy: .public) credits") + // The name now points at the buyer's identity; refresh the + // snapshots the rest of the app renders usernames from. + DWCurrentUserIdentityInfo.shared.refreshFromSDK() + } + + /// Register a name nobody owns, claimed instantly. Contested-eligible + /// labels go through `requestContestedName` instead β€” this path + /// refuses them rather than silently starting a vote. + func register(label: String) async throws { + guard !Self.isContested(label) else { + throw ServiceError.contestedName + } + let (wallet, container, identityId) = try requireOwnContext() + try await authorize() + _ = try await wallet.registerDpnsName( + identityId: identityId, + name: label, + signer: KeychainSigner(modelContainer: container)) + Self.logger.info("🏷️ MARKET :: registered \(label, privacy: .public)") + DWCurrentUserIdentityInfo.shared.refreshFromSDK() + } + + /// Request a contested-eligible name. Same `registerDpnsName` + /// transition, but the on-chain effect differs: the label enters a + /// masternode vote (~2 weeks mainnet) instead of being claimed, and + /// the transition locks the protocol's vote-resolution fund + /// (`contestedFundCredits`) from the identity balance. + /// + /// Mirrors step 3.5 of `DWIdentityRegistrationCoordinator`: the + /// 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. + /// + /// Returns the authoritative voting end time when Platform has + /// already indexed the contest, nil while indexing lags. + @discardableResult + func requestContestedName(label: String) async throws -> Date? { + let (wallet, container, identityId) = try requireOwnContext() + // Capture the network BEFORE the PIN prompt and FFI awaits β€” a + // network switch mid-flight must not scope the bookmark to the + // newly-selected network (the network-explicit overload exists + // for exactly this). + 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, + name: label, + signer: KeychainSigner(modelContainer: container)) + Self.logger.info("🏷️ MARKET :: contested request submitted for \(label, privacy: .public)") + // Bookmark BEFORE the vote-state read β€” Platform can legitimately + // return nil until the contest is indexed, and the conservative + // fallback deadline written here is what reconciliation leans on. + DWContestedNameStatusService.shared.recordSubmission(label: label, network: network) + do { + _ = try await wallet.syncContestedDpnsNames(identityId: identityId) + } catch { + Self.logger.warning("🏷️ MARKET :: syncContestedDpnsNames failed: \(String(describing: error), privacy: .public)") + } + var endTime: Date? + if let state = try? await wallet.fetchContestVoteState(identityId: identityId, label: label) { + endTime = state.endTime + DWContestedNameStatusService.shared.recordVotingEndTime(state.endTime, network: network) + } + return endTime + } + + // MARK: Contest reads + + /// Labels this identity is actively contending for, from the SDK's + /// local cache (resolved contests drop out wholesale on sync). + func myContestedNames(syncFirst: Bool = false) async -> [String] { + guard let wallet = SwiftDashSDKHost.shared.wallet, + let identityId = DWCurrentUserIdentityInfo.shared.identityId else { return [] } + if syncFirst { + _ = try? await wallet.syncContestedDpnsNames(identityId: identityId) + } + return (try? wallet.managedIdentity(identityId: identityId).getContestedDpnsNames()) ?? [] + } + + /// Live vote state for a contest this identity is part of. nil while + /// Platform hasn't indexed the contest, or once it resolved. + func contestState(label: String) async -> ContestVoteState? { + guard let wallet = SwiftDashSDKHost.shared.wallet, + let identityId = DWCurrentUserIdentityInfo.shared.identityId else { return nil } + return try? await wallet.fetchContestVoteState(identityId: identityId, label: label) + } + + /// What an unregistered contested-eligible label looks like on the + /// network BEFORE we submit. Best-effort: an unavailable query + /// reports `.unknown` (never a fabricated all-clear) and the + /// submit-time transition stays the authority. + enum ContestPrecheck: Equatable { + /// 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) + /// A past vote locked the label β€” nobody can register it. + case locked + /// The query failed; state can't be determined. + case unknown + } + + func contestPrecheck(label: String) async -> ContestPrecheck { + guard let sdk = SwiftDashSDKHost.shared.sdk else { return .unknown } + do { + let state = try await sdk.dpnsGetContestedVoteState(name: label) + 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) + } catch { + // rs-sdk reports "no contest" as an error rather than an + // empty result; a missing contest is the normal fresh case. + let text = String(describing: error).lowercased() + if text.contains("not found") || text.contains("no contest") { + return .fresh + } + Self.logger.info("🏷️ MARKET :: contest precheck unavailable for \(label, privacy: .public): \(String(describing: error), privacy: .public)") + return .unknown + } + } + + // MARK: Helpers + + /// Contested-name eligibility β€” delegates to the SDK's own + /// `dash_sdk_dpns_is_contested_username` predicate so the client + /// can't drift from the network rule. + static func isContested(_ label: String) -> Bool { + DWContestedNameStatusService.isContestedLabel(label) + } + + /// 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 + /// `vote_resolution_fund_fees::v1` in rs-platform-version + /// (20_000_000_000 credits = 0.2 DASH). + static let contestedFundCredits: UInt64 = 20_000_000_000 + + /// Buyer identity's credit balance from the persisted row β€” for the + /// affordability hint; the SDK re-checks authoritatively at purchase. + static func identityBalanceCredits(identityId: Data, container: ModelContainer) -> UInt64 { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.identityId == identityId }) + descriptor.fetchLimit = 1 + guard let identity = (try? container.mainContext.fetch(descriptor))?.first else { return 0 } + return UInt64(bitPattern: identity.balance) + } + + /// Friendlier wording for the typed SDK failures a user acts on; + /// everything else keeps the SDK's own description. + static func userFacingMessage(for error: Error) -> String { + if let walletError = error as? PlatformWalletError { + switch walletError { + case .notForSale: + return NSLocalizedString("This username is not for sale.", comment: "Username marketplace") + case .priceChanged: + return NSLocalizedString("The seller changed the price before your purchase was accepted. Review the new price and try again.", comment: "Username marketplace") + case let .insufficientIdentityCredits(_, required, available): + return String.localizedStringWithFormat( + NSLocalizedString("Your identity balance can't cover this purchase: %@ DASH needed (including a fee reserve), %@ DASH available. Top up your identity balance and try again.", comment: "Username marketplace"), + (required / 1000).dashAmount.formattedDashAmountWithoutCurrencySymbol, + (available / 1000).dashAmount.formattedDashAmountWithoutCurrencySymbol) + case .contestedNameNotTradable: + return NSLocalizedString("This name is in an active network vote and can't be traded until the contest ends.", comment: "Username marketplace") + default: + break + } + } + return error.localizedDescription + } + + private func requireOwnContext() throws -> (ManagedPlatformWallet, ModelContainer, Data) { + guard let wallet = SwiftDashSDKHost.shared.wallet, + let container = SwiftDashSDKHost.shared.modelContainer, + let identityId = DWCurrentUserIdentityInfo.shared.identityId else { + throw ServiceError.noIdentity + } + return (wallet, container, identityId) + } + + /// PIN / biometric prompt with the authorizer's errors translated to + /// service errors, so a user cancellation stays distinguishable from + /// a failed marketplace action without callers importing the + /// authorizer's symbols (same shape as + /// `SwiftDashSDKContactsService.authorize()`). + private func authorize() async throws { + do { + try await authorizer.authorize() + } catch DWIdentityAuthorizer.AuthError.cancelled { + throw ServiceError.authCancelled + } catch { + throw ServiceError.authFailed + } + } +} diff --git a/DashWallet/Sources/UI/Explore Dash/ExploreMenuScreen.swift b/DashWallet/Sources/UI/Explore Dash/ExploreMenuScreen.swift index cacb04dbf..083ed80fc 100644 --- a/DashWallet/Sources/UI/Explore Dash/ExploreMenuScreen.swift +++ b/DashWallet/Sources/UI/Explore Dash/ExploreMenuScreen.swift @@ -101,6 +101,14 @@ struct ExploreMenuScreen: View { ) .frame(minHeight: 56) + MenuItem( + title: NSLocalizedString("Username Marketplace", comment: "Username marketplace"), + subtitle: NSLocalizedString("Buy, sell and transfer DashPay usernames", comment: "Username marketplace: Explore row subtitle"), + icon: .system("at.circle.fill"), + action: { showUsernameMarketplace() } + ) + .frame(minHeight: 56) + MenuItem( title: NSLocalizedString("ATMs", comment: ""), subtitle: NSLocalizedString("Find ATMs where you can buy or sell Dash", comment: ""), @@ -219,6 +227,13 @@ struct ExploreMenuScreen: View { } } + private func showUsernameMarketplace() { + let controller = UIHostingController( + rootView: UsernameMarketplaceScreen(vc: vc)) + controller.hidesBottomBarWhenPushed = true + vc.pushViewController(controller, animated: true) + } + private func showAtms() { let atmVC = AtmListViewController() atmVC.payWithDashHandler = { onShowReceivePayment() } diff --git a/DashWallet/Sources/UI/Explore Dash/UsernameMarketplaceScreen.swift b/DashWallet/Sources/UI/Explore Dash/UsernameMarketplaceScreen.swift new file mode 100644 index 000000000..ae5d4dcc0 --- /dev/null +++ b/DashWallet/Sources/UI/Explore Dash/UsernameMarketplaceScreen.swift @@ -0,0 +1,1473 @@ +// +// UsernameMarketplaceScreen.swift +// DashWallet +// +// DPNS username marketplace (Explore tab) over the wallet-level SDK +// surface (platform #4348): search any name with live sale state, see +// the names on your identity β€” including ones that were sold or +// transferred away β€” list/re-price/delist yours, buy listed ones with +// the price pinned at confirmation, gift-transfer, register unclaimed +// non-contested labels, and read each name's full trade timeline. +// +// Names offered by other identities are explicitly labeled as sold by +// an independent user: prices are set by sellers, not by Dash or this +// app, and the UI says so on the row, in the detail sheet, and in the +// purchase confirmation. +// +// Search-driven by design: `$price` is not an indexable system +// property on Dash Platform, so a global "everything for sale" browse +// is not buildable at any layer today. +// +// dashpay target only. +// + +import SwiftUI +import SwiftDashSDK +import DashUIKit +import UIKit + +// MARK: - UsernameMarketplaceViewModel + +@MainActor +final class UsernameMarketplaceViewModel: ObservableObject { + + enum Segment: Int, CaseIterable { + case find + case mine + + var title: String { + switch self { + case .find: return NSLocalizedString("Find Names", comment: "Username marketplace: search segment") + case .mine: return NSLocalizedString("My Names", comment: "Username marketplace: owned names segment") + } + } + } + + @Published var segment: Segment = .find + @Published var query = "" + @Published var searchResults: [DpnsMarketplaceName] = [] + @Published var myNames: [DpnsNameStateRow] = [] + /// Labels this identity is contending for in active network votes. + @Published var contestedNames: [String] = [] + /// Live vote state per contested label, filled best-effort β€” a label + /// with no entry is a contest Platform hasn't indexed yet. + @Published var contestStates: [String: ContestVoteState] = [:] + @Published var isSearching = false + @Published var isLoadingMine = false + @Published var isPerformingAction = false + @Published var errorMessage: String? + /// Transient success line ("hilawe listed for 0.05 DASH"). + @Published var successMessage: String? + /// The typed query as a registrable label: valid DPNS label shape + /// AND no document exists for its normalized form. Set by + /// `updateSearch` using the SDK's own normalizer (DPNS folds + /// look-alike characters, so a plain lowercase compare would lie). + @Published var registrableQueryLabel: String? + + let service = UsernameMarketplaceService() + private var searchTask: Task? + + var ownIdentityId: Data? { DWCurrentUserIdentityInfo.shared.identityId } + + var ownedNames: [DpnsNameStateRow] { + myNames.filter { if case .owned = $0.status { return true }; return false } + } + + /// Retained rows for names that left this identity β€” sold or + /// transferred away β€” kept by the SDK so the departure is visible. + var departedNames: [DpnsNameStateRow] { + myNames.filter { if case .owned = $0.status { return false }; return true } + } + + /// DPNS label shape: 3–63 characters, ASCII letters/digits/hyphen, + /// no leading or trailing hyphen. ASCII only β€” `isLetter` alone + /// would admit unregistrable labels like "cafΓ©". + static func isValidLabel(_ label: String) -> Bool { + guard (3...63).contains(label.count) else { return false } + guard label.first != "-", label.last != "-" else { return false } + return label.allSatisfy { $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-") } + } + + func updateSearch() { + searchTask?.cancel() + let trimmed = query.trimmingCharacters(in: .whitespaces) + guard trimmed.count >= 2 else { + searchResults = [] + registrableQueryLabel = nil + isSearching = false + return + } + searchResults = [] + registrableQueryLabel = nil + isSearching = true + searchTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 350_000_000) + guard let self, !Task.isCancelled else { return } + do { + let results = try await service.search(prefix: trimmed) + guard !Task.isCancelled else { return } + searchResults = results + if UsernameMarketplaceViewModel.isValidLabel(trimmed), + let sdk = SwiftDashSDKHost.shared.sdk { + let normalized = (try? sdk.dpnsNormalizeLabel(trimmed)) ?? trimmed.lowercased() + registrableQueryLabel = results.contains { $0.normalizedLabel == normalized } + ? nil + : trimmed + } else { + registrableQueryLabel = nil + } + } catch { + guard !Task.isCancelled else { return } + errorMessage = UsernameMarketplaceService.userFacingMessage(for: error) + } + isSearching = false + } + } + + /// Local read β€” the wallet's own tracked rows plus the contested + /// labels cache, no network. Vote states for contested labels are + /// filled in best-effort afterward (those are live queries). + func loadMyNames() { + isLoadingMine = true + Task { [weak self] in + guard let self else { return } + do { + myNames = try await service.myNames() + } catch { + errorMessage = UsernameMarketplaceService.userFacingMessage(for: error) + } + contestedNames = await service.myContestedNames() + isLoadingMine = false + for label in contestedNames where contestStates[label] == nil { + if let state = await service.contestState(label: label) { + contestStates[label] = state + } + } + } + } + + /// Pull-to-refresh: run one marketplace sync pass and refresh the + /// contested cache, then re-read the local rows. + func refreshFromNetwork() async { + do { + _ = try await service.syncNow() + } catch { + errorMessage = UsernameMarketplaceService.userFacingMessage(for: error) + } + contestStates = [:] + _ = await service.myContestedNames(syncFirst: true) + loadMyNames() + } + + /// Run one PIN-gated trade action with shared progress/error/success + /// handling, then refresh both lists so the new sale state renders. + func perform( + successText: String, + _ operation: @escaping () async throws -> Void + ) { + guard !isPerformingAction else { return } + isPerformingAction = true + Task { [weak self] in + guard let self else { return } + do { + try await operation() + isPerformingAction = false + withAnimation { successMessage = successText } + loadMyNames() + updateSearch() + // Auto-hide in a detached task so the banner's 2.5 s + // doesn't keep the action buttons disabled. + Task { [weak self] in + try? await Task.sleep(nanoseconds: 2_500_000_000) + guard let self else { return } + withAnimation { + if successMessage == successText { successMessage = nil } + } + } + } catch UsernameMarketplaceService.ServiceError.authCancelled { + // Backing out of the PIN prompt is not an error state. + isPerformingAction = false + } catch { + isPerformingAction = false + errorMessage = UsernameMarketplaceService.userFacingMessage(for: error) + } + } + } +} + +// MARK: - UsernameMarketplaceScreen + +struct UsernameMarketplaceScreen: View { + private let vc: UINavigationController + + @StateObject private var viewModel = UsernameMarketplaceViewModel() + @State private var selectedLabel: SelectedMarketplaceLabel? + @State private var registerCandidate: RegisterCandidate? + + init(vc: UINavigationController) { + self.vc = vc + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavBarBack { + vc.popViewController(animated: true) + } + + TopIntro( + title: NSLocalizedString("Username Marketplace", comment: "Username marketplace"), + subtitle: NSLocalizedString("Search usernames, buy ones that are for sale, and sell or transfer your own.", comment: "Username marketplace: screen subtitle") + ) + .padding(.leading, 20) + .padding(.trailing, 60) + .padding(.top, 10) + .padding(.bottom, 12) + + Picker("", selection: $viewModel.segment) { + ForEach(UsernameMarketplaceViewModel.Segment.allCases, id: \.self) { segment in + Text(segment.title).tag(segment) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, 20) + .padding(.bottom, 10) + + switch viewModel.segment { + case .find: + findSection + case .mine: + mySection + } + + Spacer(minLength: 0) + } + .background(Color.dash.primaryBackground.ignoresSafeArea()) + .onAppear { viewModel.loadMyNames() } + .sheet(item: $selectedLabel) { selected in + MarketplaceNameDetailSheet(label: selected.label, viewModel: viewModel) + .presentationDetents([.medium, .large]) + } + .sheet(item: $registerCandidate) { candidate in + RegisterNameSheet(label: candidate.label, viewModel: viewModel) + .presentationDetents([.medium, .large]) + } + .overlay(alignment: .bottom) { + if let success = viewModel.successMessage { + Label(success, systemImage: "checkmark.circle.fill") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.dashGreen) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(Capsule().fill(Color.dash.secondaryBackground) + .shadow(color: Color.dash.shadow, radius: 16, x: 0, y: 4)) + .padding(.bottom, 24) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .alert( + NSLocalizedString("Error", comment: ""), + isPresented: Binding( + get: { viewModel.errorMessage != nil }, + set: { if !$0 { viewModel.errorMessage = nil } }) + ) { + Button(NSLocalizedString("OK", comment: ""), role: .cancel) {} + } message: { + Text(viewModel.errorMessage ?? "") + } + } + + // MARK: Find segment + + private var findSection: some View { + VStack(alignment: .leading, spacing: 0) { + ContactsSearchField( + placeholder: NSLocalizedString("Search usernames", comment: "Username marketplace: search placeholder"), + text: $viewModel.query) + .padding(.horizontal, 20) + .onChange(of: viewModel.query) { _, _ in viewModel.updateSearch() } + + ScrollView { + LazyVStack(spacing: 6) { + if viewModel.isSearching && viewModel.searchResults.isEmpty { + SwiftUI.ProgressView().padding(.top, 28) + } else if viewModel.query.trimmingCharacters(in: .whitespaces).count < 2 { + emptyHint(NSLocalizedString("Type at least 2 characters to search usernames", comment: "DashPay Contacts")) + } else { + ForEach(viewModel.searchResults) { name in + searchRow(name) + } + if let candidate = viewModel.registrableQueryLabel, !viewModel.isSearching { + registerRow(candidate) + } + if viewModel.searchResults.isEmpty && !viewModel.isSearching + && viewModel.registrableQueryLabel == nil { + emptyHint(NSLocalizedString("No usernames found", comment: "DashPay Contacts")) + } + } + } + .padding(.horizontal, 15) + .padding(.top, 12) + .padding(.bottom, 24) + } + } + } + + // MARK: My Names segment + + private var mySection: some View { + ScrollView { + LazyVStack(spacing: 6) { + if viewModel.isLoadingMine && viewModel.myNames.isEmpty && viewModel.contestedNames.isEmpty { + SwiftUI.ProgressView().padding(.top, 28) + } else if viewModel.myNames.isEmpty && viewModel.contestedNames.isEmpty { + emptyHint(NSLocalizedString("No usernames on this identity yet. Find one to buy or register on the Find Names tab.", comment: "Username marketplace: empty owned list")) + } else { + ForEach(viewModel.ownedNames) { row in + stateRow(row) + } + if !viewModel.contestedNames.isEmpty { + sectionHeader(NSLocalizedString("In network vote", comment: "Username marketplace: section of contested-name requests awaiting the masternode vote")) + ForEach(viewModel.contestedNames, id: \.self) { label in + contestedRow(label) + } + } + if !viewModel.departedNames.isEmpty { + sectionHeader(NSLocalizedString("No longer yours", comment: "Username marketplace: names that were sold or transferred away")) + ForEach(viewModel.departedNames) { row in + stateRow(row) + } + } + } + } + .padding(.horizontal, 15) + .padding(.top, 12) + .padding(.bottom, 24) + } + .refreshable { await viewModel.refreshFromNetwork() } + } + + // MARK: Rows + + private func searchRow(_ name: DpnsMarketplaceName) -> some View { + let isMine = name.isOwned(by: viewModel.ownIdentityId) + return Button { + selectedLabel = SelectedMarketplaceLabel(label: name.label) + } label: { + HStack(spacing: 10) { + ContactAvatarView( + title: name.label, + avatarURL: nil, + identitySeed: name.ownerId) + VStack(alignment: .leading, spacing: 2) { + Text(name.label) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.dash.primaryText) + .lineLimit(1) + // The seller-clarity line: a listed name that isn't + // ours is being sold by an independent user β€” not by + // Dash, not by this app. + Text(subtitle(for: name, isMine: isMine)) + .font(.system(size: 11)) + .foregroundColor(name.isForSale && !isMine ? .dashGolden : .dash.tertiaryText) + .lineLimit(1) + } + Spacer() + if name.isForSale, let priceDuffs = name.priceDuffs { + VStack(alignment: .trailing, spacing: 1) { + Text(NSLocalizedString("For sale", comment: "Username marketplace: listed badge")) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(.dashGolden) + Text("\(priceDuffs.dashAmount.formattedDashAmountWithoutCurrencySymbol) DASH") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.dash.primaryText) + } + } + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.dash.secondaryText) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.dash.secondaryBackground)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private func subtitle(for name: DpnsMarketplaceName, isMine: Bool) -> String { + if isMine { + return NSLocalizedString("Owned by you", comment: "Username marketplace") + } + if name.isForSale { + return NSLocalizedString("For sale by an independent user", comment: "Username marketplace: seller-clarity row line β€” the seller is another user, not Dash or the app") + } + let owner = name.ownerId.toBase58String() + return String(owner.prefix(8)) + "…" + String(owner.suffix(4)) + } + + private func stateRow(_ row: DpnsNameStateRow) -> some View { + Button { + selectedLabel = SelectedMarketplaceLabel(label: row.label) + } label: { + HStack(spacing: 10) { + ContactAvatarView( + title: row.label, + avatarURL: nil, + identitySeed: row.walletIdentityId) + VStack(alignment: .leading, spacing: 2) { + Text(row.label) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.dash.primaryText) + .lineLimit(1) + Text(stateLine(for: row)) + .font(.system(size: 11)) + .foregroundColor(.dash.tertiaryText) + .lineLimit(1) + } + Spacer() + if row.isForSale, let priceDuffs = row.priceDuffs { + VStack(alignment: .trailing, spacing: 1) { + Text(NSLocalizedString("For sale", comment: "Username marketplace: listed badge")) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(.dashGolden) + Text("\(priceDuffs.dashAmount.formattedDashAmountWithoutCurrencySymbol) DASH") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.dash.primaryText) + } + } + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.dash.secondaryText) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.dash.secondaryBackground)) + .contentShape(Rectangle()) + .opacity({ if case .owned = row.status { return 1 } else { return 0.65 } }()) + } + .buttonStyle(.plain) + } + + private func stateLine(for row: DpnsNameStateRow) -> String { + switch row.status { + case .owned: + return row.isForSale + ? NSLocalizedString("Listed by you", comment: "Username marketplace: own listed name") + : NSLocalizedString("Owned by you", comment: "Username marketplace") + case .sold(let buyer): + return String.localizedStringWithFormat( + NSLocalizedString("Sold to %@", comment: "Username marketplace: departed-name line β€” buyer identity"), + shortId(buyer)) + case .transferred(let recipient): + return String.localizedStringWithFormat( + NSLocalizedString("Transferred to %@", comment: "Username marketplace: departed-name line β€” recipient identity"), + shortId(recipient)) + } + } + + private func shortId(_ id: Data) -> String { + let base58 = id.toBase58String() + return String(base58.prefix(8)) + "…" + String(base58.suffix(4)) + } + + /// A contested request awaiting the masternode vote. Not tappable β€” + /// there is no document to act on until the vote resolves. + private func contestedRow(_ label: String) -> some View { + HStack(spacing: 10) { + Image(systemName: "hourglass") + .font(.system(size: 20, weight: .medium)) + .foregroundColor(.dashGolden) + .frame(width: 36, height: 36) + .background(Circle().fill(Color.dashGolden.opacity(0.12))) + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.dash.primaryText) + .lineLimit(1) + Text(contestedLine(for: label)) + .font(.system(size: 11)) + .foregroundColor(.dash.tertiaryText) + .lineLimit(1) + } + Spacer() + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.dash.secondaryBackground)) + } + + private func contestedLine(for label: String) -> String { + guard let state = viewModel.contestStates[label] else { + return NSLocalizedString("Requested β€” waiting for the network vote", comment: "Username marketplace: contested request not yet indexed by Platform") + } + return String.localizedStringWithFormat( + NSLocalizedString("Network vote ends %@", comment: "Username marketplace: contested request line β€” voting deadline"), + DWDateFormatter.sharedInstance.shortStringFromDate(state.endTime)) + } + + private func registerRow(_ label: String) -> some View { + let contested = UsernameMarketplaceService.isContested(label) + return Button { + registerCandidate = RegisterCandidate(label: label) + } label: { + HStack(spacing: 10) { + Image(systemName: "plus.circle.fill") + .font(.system(size: 26)) + .foregroundColor(.dashGreen) + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.dash.primaryText) + Text(contested + ? NSLocalizedString("Available β€” short names are decided by a network vote", comment: "Username marketplace: unregistered contested-eligible name row") + : NSLocalizedString("Available β€” register it on your identity", comment: "Username marketplace: unregistered name row")) + .font(.system(size: 11)) + .foregroundColor(contested ? .dashGolden : .dashGreen) + } + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.dash.secondaryText) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.dash.secondaryBackground)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private func sectionHeader(_ text: String) -> some View { + HStack { + Text(text) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.dash.secondaryText) + Spacer() + } + .padding(.horizontal, 4) + .padding(.top, 14) + .padding(.bottom, 2) + } + + private func emptyHint(_ text: String) -> some View { + Text(text) + .font(.system(size: 13)) + .foregroundColor(.dash.secondaryText) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.top, 28) + .padding(.horizontal, 24) + } +} + +/// Identifiable wrappers for `.sheet(item:)`. +struct SelectedMarketplaceLabel: Identifiable { + let label: String + var id: String { label } +} + +struct RegisterCandidate: Identifiable { + let label: String + var id: String { label } +} + +// MARK: - MarketplaceNameDetailSheet + +/// Authoritative detail for one name: loads the live document state and +/// the full trade timeline on appear, then offers exactly the actions +/// the state allows. Works for departed names too (the history query +/// covers names that already left the wallet). +private struct MarketplaceNameDetailSheet: View { + let label: String + @ObservedObject var viewModel: UsernameMarketplaceViewModel + @Environment(\.dismiss) private var dismiss + + @State private var liveName: DpnsMarketplaceName? + @State private var history: [DpnsNameHistoryEvent] = [] + @State private var isLoading = true + @State private var showingSetPrice = false + @State private var showingTransfer = false + @State private var confirmBuy = false + @State private var confirmDelist = false + + private var isMine: Bool { liveName?.isOwned(by: viewModel.ownIdentityId) ?? false } + + var body: some View { + ScrollView { + VStack(spacing: 0) { + ContactAvatarView( + title: label, + avatarURL: nil, + identitySeed: liveName?.ownerId ?? Data(), + size: 72) + .padding(.top, 28) + + Text(label) + .font(.system(size: 22, weight: .bold)) + .foregroundColor(.dash.primaryText) + .padding(.top, 10) + + if isLoading { + SwiftUI.ProgressView().padding(.top, 20) + } else if let name = liveName { + saleState(name) + if name.isForSale && !isMine { + independentSellerCallout + .padding(.horizontal, 20) + .padding(.top, 12) + } + infoCard(name) + .padding(.horizontal, 20) + .padding(.top, 14) + if !history.isEmpty { + historyCard + .padding(.horizontal, 20) + .padding(.top, 12) + } + actions(name) + .padding(.horizontal, 20) + .padding(.top, 18) + .padding(.bottom, 16) + } else { + Text(NSLocalizedString("This name is not registered.", comment: "Username marketplace: detail for an unregistered name")) + .font(.system(size: 13)) + .foregroundColor(.dash.secondaryText) + .padding(.top, 16) + .padding(.bottom, 24) + } + } + } + .background(Color.dash.primaryBackground) + .task { await load() } + .sheet(isPresented: $showingSetPrice) { + SetNamePriceSheet(label: label, viewModel: viewModel, onDone: { dismiss() }) + .presentationDetents([.medium, .large]) + } + .sheet(isPresented: $showingTransfer) { + TransferNameSheet(label: label, viewModel: viewModel, onDone: { dismiss() }) + .presentationDetents([.medium, .large]) + } + } + + private func load() async { + isLoading = true + // Live state and timeline are independent reads; a history + // failure must not blank the sale state (and vice versa). + do { + liveName = try await viewModel.service.nameState(label) + } catch { + viewModel.errorMessage = UsernameMarketplaceService.userFacingMessage(for: error) + } + history = (try? await viewModel.service.history(label)) ?? [] + isLoading = false + } + + @ViewBuilder + private func saleState(_ name: DpnsMarketplaceName) -> some View { + if let priceDuffs = name.priceDuffs { + VStack(spacing: 2) { + Text(NSLocalizedString("For sale", comment: "Username marketplace: listed badge")) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.dashGolden) + Text("\(priceDuffs.dashAmount.formattedDashAmountWithoutCurrencySymbol) DASH") + .font(.system(size: 18, weight: .bold)) + .foregroundColor(.dash.primaryText) + Text(CurrencyExchanger.shared.fiatAmountString(for: priceDuffs.dashAmount)) + .font(.system(size: 12)) + .foregroundColor(.dash.secondaryText) + } + .padding(.top, 8) + } else { + Text(isMine + ? NSLocalizedString("Not listed for sale", comment: "Username marketplace: own unlisted name") + : NSLocalizedString("Not for sale", comment: "Username marketplace: someone else's unlisted name")) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.dash.secondaryText) + .padding(.top, 8) + } + } + + /// The seller-clarity callout: this offer comes from an independent + /// user, not from Dash or the app. + private var independentSellerCallout: some View { + Label { + Text(NSLocalizedString("This name is offered by an independent user on the Dash network β€” not by Dash or this app. The seller sets the price.", comment: "Username marketplace: seller-clarity callout on the detail sheet")) + .font(.system(size: 12)) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: "person.crop.circle.badge.questionmark") + .font(.system(size: 13)) + } + .foregroundColor(.dash.secondaryText) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.dashGolden.opacity(0.1))) + } + + private func infoCard(_ name: DpnsMarketplaceName) -> some View { + VStack(spacing: 0) { + detailRow( + NSLocalizedString("Owner", comment: "Username marketplace"), + isMine + ? NSLocalizedString("You", comment: "Username marketplace: owner is the current user") + : shortId(name.ownerId)) + if let createdAtMs = name.createdAtMs { + detailRow( + NSLocalizedString("Registered", comment: "Username marketplace: registration date"), + DWDateFormatter.sharedInstance.shortStringFromDate( + Date(timeIntervalSince1970: Double(createdAtMs) / 1000))) + } + if let transferredAtMs = name.transferredAtMs { + detailRow( + NSLocalizedString("Last transferred", comment: "Username marketplace: latest ownership change"), + DWDateFormatter.sharedInstance.shortStringFromDate( + Date(timeIntervalSince1970: Double(transferredAtMs) / 1000))) + } + } + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.dash.secondaryBackground)) + } + + // MARK: Trade history + + private var historyCard: some View { + VStack(alignment: .leading, spacing: 0) { + Text(NSLocalizedString("Trade history", comment: "Username marketplace: timeline card title")) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.dash.secondaryText) + .padding(.horizontal, 14) + .padding(.top, 12) + .padding(.bottom, 4) + ForEach(Array(history.enumerated().reversed()), id: \.offset) { _, event in + historyRow(event) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 8) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.dash.secondaryBackground)) + } + + private func historyRow(_ event: DpnsNameHistoryEvent) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: historyIcon(event)) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.dash.blue) + .frame(width: 18) + VStack(alignment: .leading, spacing: 1) { + Text(historyText(event)) + .font(.system(size: 13)) + .foregroundColor(.dash.primaryText) + .fixedSize(horizontal: false, vertical: true) + Text(DWDateFormatter.sharedInstance.shortStringFromDate( + Date(timeIntervalSince1970: Double(event.atMs) / 1000))) + .font(.system(size: 11)) + .foregroundColor(.dash.tertiaryText) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 14) + .padding(.vertical, 7) + } + + private func historyIcon(_ event: DpnsNameHistoryEvent) -> String { + switch event { + case .registered: return "plus.circle" + case .priceSet: return "tag" + case .purchased: return "cart" + case .transferred(let from, let to, _, _): + return from == to ? "tag.slash" : "arrow.right.circle" + } + } + + private func historyText(_ event: DpnsNameHistoryEvent) -> String { + switch event { + case .registered: + return NSLocalizedString("Registered", comment: "Username marketplace: registration date") + case .priceSet(let price, _, _): + return String.localizedStringWithFormat( + NSLocalizedString("Listed for %@ DASH", comment: "Username marketplace: history β€” price set"), + (price / 1000).dashAmount.formattedDashAmountWithoutCurrencySymbol) + case .purchased(let price, let seller, let buyer, _, _): + return String.localizedStringWithFormat( + NSLocalizedString("Bought by %1$@ from %2$@ for %3$@ DASH", comment: "Username marketplace: history β€” purchase with buyer, seller, price"), + participant(buyer), participant(seller), + (price / 1000).dashAmount.formattedDashAmountWithoutCurrencySymbol) + case .transferred(let from, let to, _, _): + if from == to { + return NSLocalizedString("Removed from sale", comment: "Username marketplace: history β€” delist (transfer to self)") + } + return String.localizedStringWithFormat( + NSLocalizedString("Transferred from %1$@ to %2$@", comment: "Username marketplace: history β€” transfer with both identities"), + participant(from), participant(to)) + } + } + + private func participant(_ id: Data) -> String { + id == viewModel.ownIdentityId + ? NSLocalizedString("you", comment: "Username marketplace: history participant is the current user") + : shortId(id) + } + + private func shortId(_ id: Data) -> String { + let base58 = id.toBase58String() + return String(base58.prefix(8)) + "…" + String(base58.suffix(4)) + } + + // MARK: Actions + + @ViewBuilder + private func actions(_ name: DpnsMarketplaceName) -> some View { + VStack(spacing: 10) { + if isMine { + if name.isForSale { + primaryButton(NSLocalizedString("Change Price", comment: "Username marketplace")) { + showingSetPrice = true + } + secondaryButton(NSLocalizedString("Remove From Sale", comment: "Username marketplace")) { + confirmDelist = true + } + } else { + primaryButton(NSLocalizedString("Put Up For Sale", comment: "Username marketplace")) { + showingSetPrice = true + } + } + secondaryButton(NSLocalizedString("Transfer to Another Identity", comment: "Username marketplace")) { + showingTransfer = true + } + } else if name.isForSale, let priceDuffs = name.priceDuffs { + identityBalanceLine(name) + primaryButton(String.localizedStringWithFormat( + NSLocalizedString("Buy for %@ DASH", comment: "Username marketplace: purchase button"), + priceDuffs.dashAmount.formattedDashAmountWithoutCurrencySymbol)) { + confirmBuy = true + } + } + } + .disabled(viewModel.isPerformingAction) + .confirmationDialog( + String.localizedStringWithFormat( + NSLocalizedString("Buy β€œ%@”?", comment: "Username marketplace: purchase confirmation title"), + label), + isPresented: $confirmBuy, + titleVisibility: .visible + ) { + Button(NSLocalizedString("Buy", comment: "Username marketplace"), role: .none) { + // The Buy button only renders for a listed name, but never + // let a nil price fall through as a 0-credit purchase. + guard let expected = name.priceCredits else { + viewModel.errorMessage = NSLocalizedString("This username is not for sale.", comment: "Username marketplace") + return + } + viewModel.perform(successText: String.localizedStringWithFormat( + NSLocalizedString("%@ is now yours", comment: "Username marketplace: purchase success"), + label)) { + try await viewModel.service.purchase(name: label, expectedPriceCredits: expected) + } + dismiss() + } + Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) {} + } message: { + Text(NSLocalizedString("You are buying from an independent user, not from Dash or this app. The price plus a small network fee is paid from your identity balance, and the name moves to your identity immediately.", comment: "Username marketplace: purchase confirmation body with seller clarity")) + } + .confirmationDialog( + String.localizedStringWithFormat( + NSLocalizedString("Remove β€œ%@” from sale?", comment: "Username marketplace: delist confirmation title"), + label), + isPresented: $confirmDelist, + titleVisibility: .visible + ) { + Button(NSLocalizedString("Remove From Sale", comment: "Username marketplace"), role: .destructive) { + viewModel.perform(successText: String.localizedStringWithFormat( + NSLocalizedString("%@ is no longer for sale", comment: "Username marketplace: delist success"), + label)) { + try await viewModel.service.removeFromSale(name: label) + } + dismiss() + } + Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) {} + } message: { + Text(NSLocalizedString("Removing the listing is a network transaction with a small fee, paid from your identity balance. You keep the name.", comment: "Username marketplace: delist confirmation body")) + } + } + + /// Buyer-side affordability line, from the same persisted identity + /// balance the profile sheet shows. The SDK re-checks + /// authoritatively at purchase time. + @ViewBuilder + private func identityBalanceLine(_ name: DpnsMarketplaceName) -> some View { + if let identityId = viewModel.ownIdentityId, + let container = SwiftDashSDKHost.shared.modelContainer { + let available = UsernameMarketplaceService.identityBalanceCredits( + identityId: identityId, container: container) + let needed = name.priceCredits ?? 0 + HStack { + Text(NSLocalizedString("Identity Account Balance", comment: "SDK identity profile sheet β€” the identity's credit balance")) + .font(.system(size: 12)) + .foregroundColor(.dash.secondaryText) + Spacer() + Text("\((available / 1000).dashAmount.formattedDashAmountWithoutCurrencySymbol) DASH") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(available > needed ? .dash.primaryText : .orange) + } + if available <= needed { + Text(NSLocalizedString("Not enough identity credits for this purchase β€” top up from My Profile first.", comment: "Username marketplace: insufficient balance hint")) + .font(.system(size: 11)) + .foregroundColor(.orange) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + + private func detailRow(_ title: String, _ value: String) -> some View { + HStack { + Text(title) + .font(.system(size: 13)) + .foregroundColor(.dash.secondaryText) + Spacer() + Text(value) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.dash.primaryText) + .multilineTextAlignment(.trailing) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + } + + private func primaryButton(_ title: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(title) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(Color.dash.whiteText) + .frame(maxWidth: .infinity) + .padding(.vertical, 13) + .background(Color.dash.blue) + .cornerRadius(12) + } + .buttonStyle(.plain) + } + + private func secondaryButton(_ title: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(title) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(.dash.blue) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.dash.blue.opacity(0.08))) + } + .buttonStyle(.plain) + } +} + +// MARK: - SetNamePriceSheet + +private struct SetNamePriceSheet: View { + let label: String + @ObservedObject var viewModel: UsernameMarketplaceViewModel + let onDone: () -> Void + + @Environment(\.dismiss) private var dismiss + @State private var priceText = "" + @FocusState private var fieldFocused: Bool + + private var priceDuffs: UInt64? { + let normalized = priceText + .trimmingCharacters(in: .whitespaces) + .replacingOccurrences(of: ",", with: ".") + guard let dash = Decimal(string: normalized), dash > 0, dash <= 100_000 else { return nil } + let duffsDecimal = dash * Decimal(kOneDash) + return UInt64(exactly: NSDecimalNumber(decimal: duffsDecimal).int64Value) + } + + var body: some View { + ScrollView { + VStack(spacing: 0) { + Text(String.localizedStringWithFormat( + NSLocalizedString("Set a price for β€œ%@”", comment: "Username marketplace: set price sheet title"), + label)) + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.dash.primaryText) + .multilineTextAlignment(.center) + .padding(.top, 28) + .padding(.horizontal, 24) + + Text(NSLocalizedString("Anyone will be able to buy the name for this exact price. The proceeds arrive as credits on your identity balance.", comment: "Username marketplace: set price sheet body")) + .font(.system(size: 14)) + .foregroundColor(.dash.secondaryText) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 28) + .padding(.top, 8) + + VStack(alignment: .leading, spacing: 4) { + TextField( + NSLocalizedString("Price in DASH", comment: "Username marketplace: price placeholder"), + text: $priceText) + .keyboardType(.decimalPad) + .focused($fieldFocused) + .font(.system(size: 18, weight: .semibold)) + .padding(14) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.dash.secondaryBackground)) + if let duffs = priceDuffs { + Text(CurrencyExchanger.shared.fiatAmountString(for: duffs.dashAmount)) + .font(.system(size: 12)) + .foregroundColor(.dash.secondaryText) + } + } + .padding(.horizontal, 20) + .padding(.top, 18) + + Text(NSLocalizedString("Listing is a network transaction with a small fee, paid from your identity balance.", comment: "Username marketplace: set price fee note")) + .font(.system(size: 12)) + .foregroundColor(.dash.tertiaryText) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + .padding(.top, 10) + + Button { + guard let duffs = priceDuffs else { return } + viewModel.perform(successText: String.localizedStringWithFormat( + NSLocalizedString("%1$@ listed for %2$@ DASH", comment: "Username marketplace: listing success β€” name, then price"), + label, + duffs.dashAmount.formattedDashAmountWithoutCurrencySymbol)) { + try await viewModel.service.setPrice(name: label, priceDuffs: duffs) + } + dismiss() + onDone() + } label: { + Text(NSLocalizedString("List For Sale", comment: "Username marketplace: confirm listing button")) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(Color.dash.whiteText) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color.dash.blue) + .cornerRadius(12) + } + .buttonStyle(.plain) + .disabled(priceDuffs == nil || viewModel.isPerformingAction) + .opacity(priceDuffs == nil ? 0.55 : 1) + .padding(.horizontal, 20) + .padding(.top, 20) + + Button { + dismiss() + } label: { + Text(NSLocalizedString("Cancel", comment: "")) + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.dash.blue) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.plain) + .padding(.horizontal, 20) + .padding(.bottom, 10) + } + } + .background(Color.dash.primaryBackground) + .onAppear { fieldFocused = true } + } +} + +// MARK: - TransferNameSheet + +private struct TransferNameSheet: View { + let label: String + @ObservedObject var viewModel: UsernameMarketplaceViewModel + let onDone: () -> Void + + @Environment(\.dismiss) private var dismiss + @State private var recipientText = "" + @State private var isResolving = false + @State private var resolveError: String? + + var body: some View { + ScrollView { + VStack(spacing: 0) { + Text(String.localizedStringWithFormat( + NSLocalizedString("Transfer β€œ%@”", comment: "Username marketplace: transfer sheet title"), + label)) + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.dash.primaryText) + .padding(.top, 28) + + Text(NSLocalizedString("Send this name to another identity for free. The transfer also removes any sale listing. This can't be undone β€” the new owner would have to transfer it back.", comment: "Username marketplace: transfer sheet body")) + .font(.system(size: 14)) + .foregroundColor(.dash.secondaryText) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 28) + .padding(.top, 8) + + VStack(alignment: .leading, spacing: 4) { + TextField( + NSLocalizedString("Recipient username or identity ID", comment: "Username marketplace: transfer recipient placeholder"), + text: $recipientText) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .font(.system(size: 15)) + .padding(14) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.dash.secondaryBackground)) + if let resolveError { + Text(resolveError) + .font(.system(size: 12)) + .foregroundColor(.orange) + } + } + .padding(.horizontal, 20) + .padding(.top, 18) + + Button { + transfer() + } label: { + if isResolving { + SwiftUI.ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + } else { + Text(NSLocalizedString("Transfer", comment: "Username marketplace: confirm transfer button")) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(Color.dash.whiteText) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color.dash.blue) + .cornerRadius(12) + } + } + .buttonStyle(.plain) + .disabled(recipientText.trimmingCharacters(in: .whitespaces).isEmpty + || isResolving || viewModel.isPerformingAction) + .padding(.horizontal, 20) + .padding(.top, 20) + + Button { + dismiss() + } label: { + Text(NSLocalizedString("Cancel", comment: "")) + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.dash.blue) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.plain) + .padding(.horizontal, 20) + .padding(.bottom, 10) + } + } + .background(Color.dash.primaryBackground) + } + + /// Accepts a base58 identity id directly, or resolves a username via + /// DPNS. Never guesses: unresolvable input surfaces an error and no + /// transition is attempted. + private func transfer() { + let input = recipientText.trimmingCharacters(in: .whitespaces) + resolveError = nil + if let direct = Data.identifier(fromBase58: input), direct.count == 32 { + run(recipientBase58: input) + return + } + guard let wallet = SwiftDashSDKHost.shared.wallet else { + resolveError = NSLocalizedString("Wallet is not ready", comment: "DashPay") + return + } + isResolving = true + Task { + defer { isResolving = false } + do { + guard let identityId = try await wallet.resolveDpnsName(input) else { + resolveError = NSLocalizedString("No identity owns that username.", comment: "Username marketplace: unresolvable transfer recipient") + return + } + run(recipientBase58: identityId.toBase58String()) + } catch { + resolveError = UsernameMarketplaceService.userFacingMessage(for: error) + } + } + } + + private func run(recipientBase58: String) { + viewModel.perform(successText: String.localizedStringWithFormat( + NSLocalizedString("%@ transferred", comment: "Username marketplace: transfer success"), + label)) { + try await viewModel.service.transfer(name: label, toIdentityBase58: recipientBase58) + } + dismiss() + onDone() + } +} + +// MARK: - RegisterNameSheet + +private struct RegisterNameSheet: View { + let label: String + @ObservedObject var viewModel: UsernameMarketplaceViewModel + @Environment(\.dismiss) private var dismiss + + /// Pre-submit network state for contested labels; nil while loading. + @State private var precheck: UsernameMarketplaceService.ContestPrecheck? + + private var isContested: Bool { + UsernameMarketplaceService.isContested(label) + } + + /// This identity already has a request in for this label β€” the vote + /// is in progress and a second submission would just fail. Checks + /// the app bookmark too, in case the SDK cache hasn't synced yet. + private var alreadyRequested: Bool { + viewModel.contestedNames.contains { + DWContestedNameStatusService.labelsMatch($0, label) + } || DWContestedNameStatusService.shared.isPendingLabel(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. + private var canAffordRequest: Bool { + guard let identityId = viewModel.ownIdentityId, + let container = SwiftDashSDKHost.shared.modelContainer else { return false } + return UsernameMarketplaceService.identityBalanceCredits( + identityId: identityId, container: container) + > UsernameMarketplaceService.contestedFundCredits + } + + var body: some View { + ScrollView { + VStack(spacing: 0) { + Image(systemName: isContested ? "checkmark.seal" : "plus.circle.fill") + .font(.system(size: 36)) + .foregroundColor(isContested ? .dashGolden : .dashGreen) + .frame(width: 76, height: 76) + .background(Circle().fill((isContested ? Color.dashGolden : Color.dashGreen).opacity(0.1))) + .padding(.top, 28) + + Text(String.localizedStringWithFormat( + isContested + ? NSLocalizedString("Request β€œ%@”", comment: "Username marketplace: contested register sheet title") + : NSLocalizedString("Register β€œ%@”", comment: "Username marketplace: register sheet title"), + label)) + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.dash.primaryText) + .padding(.top, 14) + + if isContested { + contestedContent + } else { + Text(NSLocalizedString("The name is registered directly on your identity. Registration is a network transaction paid from your identity balance.", comment: "Username marketplace: register sheet body")) + .font(.system(size: 14)) + .foregroundColor(.dash.secondaryText) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 28) + .padding(.top, 8) + + confirmButton(NSLocalizedString("Register", comment: "Username marketplace: confirm register button")) { + viewModel.perform(successText: String.localizedStringWithFormat( + NSLocalizedString("%@ registered", comment: "Username marketplace: registration success"), + label)) { + try await viewModel.service.register(label: label) + } + dismiss() + } + } + + Button { + dismiss() + } label: { + Text(NSLocalizedString("Cancel", comment: "")) + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.dash.blue) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.plain) + .padding(.horizontal, 20) + .padding(.top, 8) + .padding(.bottom, 10) + } + } + .background(Color.dash.primaryBackground) + .task { + guard isContested, precheck == nil else { return } + precheck = await viewModel.service.contestPrecheck(label: label) + } + } + + // MARK: Contested request + + @ViewBuilder private var contestedContent: some View { + Text(NSLocalizedString("Short names β€” 19 characters or fewer, letters and numbers only β€” aren't registered instantly. The Dash network votes on who gets them.", comment: "Username marketplace: contested request explainer, paragraph 1")) + .font(.system(size: 14)) + .foregroundColor(.dash.secondaryText) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 28) + .padding(.top, 8) + + if alreadyRequested { + statusCallout( + 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")) + } 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) + .padding(.bottom, 8) + } else if precheck == .locked { + statusCallout( + icon: "lock", + text: NSLocalizedString("The network voted to lock this name, so nobody can register it. Choose a different name.", comment: "Username marketplace: contested label locked by a past vote")) + } else { + Text(NSLocalizedString("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.", comment: "Username marketplace: contested request explainer, paragraph 2")) + .font(.system(size: 14)) + .foregroundColor(.dash.secondaryText) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 28) + .padding(.top, 10) + + if case .activeContest = precheck { + statusCallout( + icon: "person.2", + text: NSLocalizedString("This name is already in an active network vote. Your request joins it as another contender.", comment: "Username marketplace: contested label already has an active vote")) + } + + requestCostCard + .padding(.horizontal, 20) + .padding(.top, 14) + + confirmButton(NSLocalizedString("Request Username", comment: "Username marketplace: confirm contested request button")) { + viewModel.perform(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) + } + dismiss() + } + .disabled(!canAffordRequest) + .opacity(canAffordRequest ? 1 : 0.55) + } + } + + /// The vote-resolution fund the request locks from the identity + /// balance (a protocol constant), next to what's available. + @ViewBuilder private var requestCostCard: some View { + let fund = UsernameMarketplaceService.contestedFundCredits + let available: UInt64 = { + guard let identityId = viewModel.ownIdentityId, + let container = SwiftDashSDKHost.shared.modelContainer else { return 0 } + return UsernameMarketplaceService.identityBalanceCredits( + identityId: identityId, container: container) + }() + VStack(spacing: 0) { + HStack { + Text(NSLocalizedString("Request cost", comment: "Username marketplace: contested request cost row")) + .font(.system(size: 13)) + .foregroundColor(.dash.secondaryText) + Spacer() + Text(String.localizedStringWithFormat( + NSLocalizedString("%@ DASH + network fee", comment: "Username marketplace: contested request cost value β€” the vote-resolution fund amount"), + (fund / 1000).dashAmount.formattedDashAmountWithoutCurrencySymbol)) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.dash.primaryText) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + HStack { + Text(NSLocalizedString("Identity Account Balance", comment: "SDK identity profile sheet β€” the identity's credit balance")) + .font(.system(size: 13)) + .foregroundColor(.dash.secondaryText) + Spacer() + Text("\((available / 1000).dashAmount.formattedDashAmountWithoutCurrencySymbol) DASH") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(available > fund ? .dash.primaryText : .orange) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + Text(NSLocalizedString("The cost is paid from your identity balance. It funds the network vote and isn't returned if another contender wins.", comment: "Username marketplace: contested request cost footnote")) + .font(.system(size: 11)) + .foregroundColor(.dash.tertiaryText) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.bottom, 10) + if available <= fund { + Text(NSLocalizedString("Not enough identity credits for this request β€” top up from My Profile first.", comment: "Username marketplace: insufficient balance hint for a contested request")) + .font(.system(size: 11)) + .foregroundColor(.orange) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 14) + .padding(.bottom, 10) + } + } + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color.dash.secondaryBackground)) + } + + private func statusCallout(icon: String, text: String) -> some View { + Label { + Text(text) + .font(.system(size: 12)) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: icon) + .font(.system(size: 13)) + } + .foregroundColor(.dash.secondaryText) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color.dashGolden.opacity(0.1))) + .padding(.horizontal, 20) + .padding(.top, 12) + } + + private func confirmButton(_ title: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(title) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(Color.dash.whiteText) + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + .background(Color.dash.blue) + .cornerRadius(12) + } + .buttonStyle(.plain) + .disabled(viewModel.isPerformingAction) + .padding(.horizontal, 20) + .padding(.top, 20) + } +} diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index a069373fc..e6205287b 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -7,6 +7,9 @@ /* CrowdNode */ " Terms of Use " = " Terms of Use "; +/* Username marketplace: listing success β€” name, then price */ +"%1$@ listed for %2$@ DASH" = "%1$@ listed for %2$@ DASH"; + /* DashPay Notifications */ "%@ accepted your contact request" = "%@ accepted your contact request"; @@ -22,6 +25,9 @@ /* DashPay Contacts: scanned username doesn't resolve to the scanned identity */ "%@ couldn't be verified on the Dash network. The QR code may be outdated." = "%@ couldn't be verified on the Dash network. The QR code may be outdated."; +/* Username marketplace: contested request cost value β€” the vote-resolution fund amount */ +"%@ DASH + network fee" = "%@ DASH + network fee"; + /* Usernames */ "%@ Dash available" = "%@ Dash available"; @@ -43,6 +49,9 @@ /* Username has sent you a contact request */ "%@ has sent you a contact request" = "%@ has sent you a contact request"; +/* Username marketplace: delist success */ +"%@ is no longer for sale" = "%@ is no longer for sale"; + /* No comment provided by engineer. */ "%@ is not allowed to access Face ID. Allow Face ID access in Settings" = "%@ is not allowed to access Face ID. Allow Face ID access in Settings"; @@ -52,9 +61,18 @@ /* No comment provided by engineer. */ "%@ is not allowed to access Touch ID. Allow Touch ID access in Settings" = "%@ is not allowed to access Touch ID. Allow Touch ID access in Settings"; +/* Username marketplace: purchase success */ +"%@ is now yours" = "%@ is now yours"; + +/* Username marketplace: registration success */ +"%@ registered" = "%@ registered"; + /* Testnet faucet */ "%@ tDash requested β€” it will arrive shortly" = "%@ tDash requested β€” it will arrive shortly"; +/* Username marketplace: transfer success */ +"%@ transferred" = "%@ transferred"; + /* DashPay Contacts */ "%@ wants to connect. Accept to pay each other directly by username." = "%@ wants to connect. Accept to pay each other directly by username."; @@ -354,6 +372,9 @@ /* Balance info sheet */ "Anyone who knows an address can view its history" = "Anyone who knows an address can view its history"; +/* Username marketplace: set price sheet body */ +"Anyone will be able to buy the name for this exact price. The proceeds arrive as credits on your identity balance." = "Anyone will be able to buy the name for this exact price. The proceeds arrive as credits on your identity balance."; + /* AboutDash */ "App version" = "App version"; @@ -428,6 +449,12 @@ /* SPV diagnostics */ "Available only while SPV is running with a wallet bound." = "Available only while SPV is running with a wallet bound."; +/* Username marketplace: unregistered name row */ +"Available β€” register it on your identity" = "Available β€” register it on your identity"; + +/* Username marketplace: unregistered contested-eligible name row */ +"Available β€” short names are decided by a network vote" = "Available β€” short names are decided by a network vote"; + /* DashPay Contacts */ "Available: %@ DASH" = "Available: %@ DASH"; @@ -517,6 +544,9 @@ /* Voting */ "Blocked '%@' username" = "Blocked '%@' username"; +/* Username marketplace: history β€” purchase with buyer, seller, price */ +"Bought by %1$@ from %2$@ for %3$@ DASH" = "Bought by %1$@ from %2$@ for %3$@ DASH"; + /* Identities: this key only signs for one data contract */ "Bound to contract" = "Bound to contract"; @@ -563,6 +593,9 @@ /* Swap */ "Buy flow is not supported by this swap backend" = "Buy flow is not supported by this swap backend"; +/* Username marketplace: purchase button */ +"Buy for %@ DASH" = "Buy for %@ DASH"; + /* DashSpend */ "Buy gift card" = "Buy gift card"; @@ -572,6 +605,12 @@ /* No comment provided by engineer. */ "Buy gift cards with your Dash for the exact amount of your purchase." = "Buy gift cards with your Dash for the exact amount of your purchase."; +/* Username marketplace: purchase confirmation title */ +"Buy β€œ%@”?" = "Buy β€œ%@”?"; + +/* Username marketplace: Explore row subtitle */ +"Buy, sell and transfer DashPay usernames" = "Buy, sell and transfer DashPay usernames"; + /* Buy/Sell */ "Buy/Sell" = "Buy/Sell"; @@ -620,6 +659,9 @@ /* No comment provided by engineer. */ "Change PIN" = "Change PIN"; +/* Username marketplace */ +"Change Price" = "Change Price"; + /* TimeSkew */ "Check date & time settings" = "Check date & time settings"; @@ -686,6 +728,15 @@ /* Identity top-up sheet β€” fee note */ "Fees are taken from the amount; from Shielded, a small remainder may stay in your Platform balance." = "Fees are taken from the amount; from Shielded, a small remainder may stay in your Platform balance."; +/* Username marketplace: search segment */ +"Find Names" = "Find Names"; + +/* Username marketplace: listed badge */ +"For sale" = "For sale"; + +/* Username marketplace: seller-clarity row line β€” the seller is another user, not Dash or the app */ +"For sale by an independent user" = "For sale by an independent user"; + /* Identity top-up sheet β€” linkability warning for transparent-side sources */ "Funding from a transparent balance publicly links those coins to your identity on the Dash chain. For privacy, pay from your Shielded balance." = "Funding from a transparent balance publicly links those coins to your identity on the Dash chain. For privacy, pay from your Shielded balance."; @@ -698,30 +749,81 @@ /* SDK identity profile sheet β€” the identity's credit balance */ "Identity Account Balance" = "Identity Account Balance"; +/* Username marketplace: section of contested-name requests awaiting the masternode vote */ +"In network vote" = "In network vote"; + +/* Username marketplace: latest ownership change */ +"Last transferred" = "Last transferred"; + +/* Username marketplace: confirm listing button */ +"List For Sale" = "List For Sale"; + +/* Username marketplace: own listed name */ +"Listed by you" = "Listed by you"; + +/* Username marketplace: history β€” price set */ +"Listed for %@ DASH" = "Listed for %@ DASH"; + +/* Username marketplace: set price fee note */ +"Listing is a network transaction with a small fee, paid from your identity balance." = "Listing is a network transaction with a small fee, paid from your identity balance."; + /* DashPay: banner fallback when the identity has no name yet */ "My identity" = "My identity"; +/* Username marketplace: owned names segment */ +"My Names" = "My Names"; + /* DashPay FAQ */ "Name services like Unstoppable Domains map a human-readable name to a fixed public address on-chain. Anyone can resolve the name and see every payment ever made to it. A DashPay username never publicly resolves to a payment address β€” addresses are revealed only inside the encrypted exchange with each contact, so your name and your money stay unlinked to outside observers." = "Name services like Unstoppable Domains map a human-readable name to a fixed public address on-chain. Anyone can resolve the name and see every payment ever made to it. A DashPay username never publicly resolves to a payment address β€” addresses are revealed only inside the encrypted exchange with each contact, so your name and your money stay unlinked to outside observers."; +/* Username marketplace: contested request line β€” voting deadline */ +"Network vote ends %@" = "Network vote ends %@"; + +/* Username marketplace: unresolvable transfer recipient */ +"No identity owns that username." = "No identity owns that username."; + +/* Username marketplace: names that were sold or transferred away */ +"No longer yours" = "No longer yours"; + /* Identity top-up sheet β€” missing unshield destination */ "No Platform receive address is available yet β€” wait for the Platform sync to finish and try again." = "No Platform receive address is available yet β€” wait for the Platform sync to finish and try again."; +/* Username marketplace: empty owned list */ +"No usernames on this identity yet. Find one to buy or register on the Find Names tab." = "No usernames on this identity yet. Find one to buy or register on the Find Names tab."; + /* Identity top-up sheet β€” insufficient funding source */ "Not enough funds in this balance: %@ DASH needed, %@ DASH available." = "Not enough funds in this balance: %@ DASH needed, %@ DASH available."; +/* Username marketplace: insufficient balance hint */ +"Not enough identity credits for this purchase β€” top up from My Profile first." = "Not enough identity credits for this purchase β€” top up from My Profile first."; + +/* Username marketplace: insufficient balance hint for a contested request */ +"Not enough identity credits for this request β€” top up from My Profile first." = "Not enough identity credits for this request β€” top up from My Profile first."; + +/* Username marketplace: someone else's unlisted name */ +"Not for sale" = "Not for sale"; + +/* Username marketplace: own unlisted name */ +"Not listed for sale" = "Not listed for sale"; + /* DashPay FAQ */ "On Bitcoin there is no name layer, so people share and reuse addresses out in the open β€” once an address is known, everything it ever received is linkable to its owner. With DashPay your username is public, but it never points at a payment address: addresses are exchanged privately per contact and rotate, so paying by name doesn't publish where your money goes. Both are transparent chains, so chain analysis still applies to the coins themselves." = "On Bitcoin there is no name layer, so people share and reuse addresses out in the open β€” once an address is known, everything it ever received is linkable to its owner. With DashPay your username is public, but it never points at a payment address: addresses are exchanged privately per contact and rotate, so paying by name doesn't publish where your money goes. Both are transparent chains, so chain analysis still applies to the coins themselves."; /* DashPay Contacts: search results from DPNS, not the local contact list */ "On the Dash network" = "On the Dash network"; +/* Username marketplace */ +"Owned by you" = "Owned by you"; + /* Identity top-up sheet β€” funding source picker label */ "Pay from" = "Pay from"; /* DashPay FAQ */ "Payments are private: the addresses you exchange with a contact travel inside an encrypted payload only the two of you can read, and are never published β€” so your payments aren't trivially linkable to your username. They are still regular transparent-chain payments, though: sophisticated chain analysis might leak information. Shielded DashPay (coming soon) will close that gap. Contact requests themselves are currently NOT private: anyone can see that two identities are connected. Private contact requests are a feature coming soon. Your username and profile are also public on Dash Platform." = "Payments are private: the addresses you exchange with a contact travel inside an encrypted payload only the two of you can read, and are never published β€” so your payments aren't trivially linkable to your username. They are still regular transparent-chain payments, though: sophisticated chain analysis might leak information. Shielded DashPay (coming soon) will close that gap. Contact requests themselves are currently NOT private: anyone can see that two identities are connected. Private contact requests are a feature coming soon. Your username and profile are also public on Dash Platform."; +/* Username marketplace: price placeholder */ +"Price in DASH" = "Price in DASH"; + /* DashPay FAQ */ "Private contact requests are in development and expected around September–October 2026. Today the request itself is public β€” anyone can see that two identities are connected, even though the payment details inside it are encrypted. Once private contact requests ship, you will have the choice of having your friendship be public or private." = "Private contact requests are in development and expected around September–October 2026. Today the request itself is public β€” anyone can see that two identities are connected, even though the payment details inside it are encrypted. Once private contact requests ship, you will have the choice of having your friendship be public or private."; @@ -2338,21 +2440,66 @@ /* Identities: what a public key is used for */ "Purpose" = "Purpose"; +/* Username marketplace */ +"Put Up For Sale" = "Put Up For Sale"; + /* Identities: key visible to the wallet but not usable for signing */ "Read-only" = "Read-only"; /* Retry a stuck balance transfer whose transaction never confirmed */ "Rebroadcast" = "Rebroadcast"; +/* Username marketplace: transfer recipient placeholder */ +"Recipient username or identity ID" = "Recipient username or identity ID"; + /* Identity top-up sheet β€” shielded source note */ "Recommended: a two-step transfer through your own Platform address keeps your identity unlinked from your transparent coins." = "Recommended: a two-step transfer through your own Platform address keeps your identity unlinked from your transparent coins."; +/* Username marketplace: register sheet title */ +"Register β€œ%@”" = "Register β€œ%@”"; + +/* Username marketplace: registration date */ +"Registered" = "Registered"; + +/* Username marketplace */ +"Remove From Sale" = "Remove From Sale"; + +/* Username marketplace: delist confirmation title */ +"Remove β€œ%@” from sale?" = "Remove β€œ%@” from sale?"; + +/* Username marketplace: history β€” delist (transfer to self) */ +"Removed from sale" = "Removed from sale"; + +/* Username marketplace: delist confirmation body */ +"Removing the listing is a network transaction with a small fee, paid from your identity balance. You keep the name." = "Removing the listing is a network transaction with a small fee, paid from your identity balance. You keep the name."; + +/* Username marketplace: contested request cost row */ +"Request cost" = "Request cost"; + +/* Username marketplace: contested request success */ +"Request for %@ submitted β€” masternodes now vote on it" = "Request for %@ submitted β€” masternodes now vote on it"; + +/* Username marketplace: confirm contested request button */ +"Request Username" = "Request Username"; + +/* Username marketplace: contested register sheet title */ +"Request β€œ%@”" = "Request β€œ%@”"; + +/* Username marketplace: contested request not yet indexed by Platform */ +"Requested β€” waiting for the network vote" = "Requested β€” waiting for the network vote"; + /* Asset-lock retry in progress */ "Retrying transfer…" = "Retrying transfer…"; +/* Username marketplace: screen subtitle */ +"Search usernames, buy ones that are for sale, and sell or transfer your own." = "Search usernames, buy ones that are for sale, and sell or transfer your own."; + /* Identities: DPP security level of a public key */ "Security level" = "Security level"; +/* Username marketplace: transfer sheet body */ +"Send this name to another identity for free. The transfer also removes any sale listing. This can't be undone β€” the new owner would have to transfer it back." = "Send this name to another identity for free. The transfer also removes any sale listing. This can't be undone β€” the new owner would have to transfer it back."; + /* DashPay intro: feature body */ "Send to a username you can actually remember and verify." = "Send to a username you can actually remember and verify."; @@ -2365,15 +2512,42 @@ /* DashPay: CTA of the enable success sheet */ "Send your first contact request" = "Send your first contact request"; +/* Username marketplace: set price sheet title */ +"Set a price for β€œ%@”" = "Set a price for β€œ%@”"; + +/* Username marketplace */ +"Short names are decided by a network vote β€” use Request Username instead." = "Short names are decided by a network vote β€” use Request Username instead."; + +/* Username marketplace: contested request explainer, paragraph 1 */ +"Short names β€” 19 characters or fewer, letters and numbers only β€” aren't registered instantly. The Dash network votes on who gets them." = "Short names β€” 19 characters or fewer, letters and numbers only β€” aren't registered instantly. The Dash network votes on who gets them."; + /* DashPay Contacts: expands the network search into the full add-contact flow */ "Show more results" = "Show more results"; +/* Username marketplace: departed-name line β€” buyer identity */ +"Sold to %@" = "Sold to %@"; + /* Identity top-up sheet β€” shielded route progress */ "Step 1 of 2 β€” moving Dash out of your Shielded balance…" = "Step 1 of 2 β€” moving Dash out of your Shielded balance…"; /* Identity top-up sheet β€” shielded route progress */ "Step 2 of 2 β€” topping up your identity…" = "Step 2 of 2 β€” topping up your identity…"; +/* Username marketplace: contested request cost footnote */ +"The cost is paid from your identity balance. It funds the network vote and isn't returned if another contender wins." = "The cost is paid from your identity balance. It funds the network vote and isn't returned if another contender wins."; + +/* Username marketplace: register sheet body */ +"The name is registered directly on your identity. Registration is a network transaction paid from your identity balance." = "The name is registered directly on your identity. Registration is a network transaction paid from your identity balance."; + +/* Username marketplace: contested label locked by a past vote */ +"The network voted to lock this name, so nobody can register it. Choose a different name." = "The network voted to lock this name, so nobody can register it. Choose a different name."; + +/* Username marketplace */ +"The recipient identity couldn't be resolved." = "The recipient identity couldn't be resolved."; + +/* Username marketplace */ +"The seller changed the price before your purchase was accepted. Review the new price and try again." = "The seller changed the price before your purchase was accepted. Review the new price and try again."; + /* DashPay: body of the Enable DashPay confirmation */ "This adds two keys to your identity so other users can send you contact requests, and so you can accept theirs. This is a one time event." = "This adds two keys to your identity so other users can send you contact requests, and so you can accept theirs. This is a one time event."; @@ -2383,9 +2557,30 @@ /* SDK identity profile sheet β€” explains the identity credit balance */ "This balance pays Dash Platform network fees β€” usernames, contact requests, profile updates. It belongs to your identity, not your wallet: unlike your Transparent, Platform, and Shielded balances it can't be spent as regular Dash. Topping up converts Dash from a balance you choose β€” Shielded by default β€” into identity credits." = "This balance pays Dash Platform network fees β€” usernames, contact requests, profile updates. It belongs to your identity, not your wallet: unlike your Transparent, Platform, and Shielded balances it can't be spent as regular Dash. Topping up converts Dash from a balance you choose β€” Shielded by default β€” into identity credits."; +/* Username marketplace: contested label already has an active vote */ +"This name is already in an active network vote. Your request joins it as another contender." = "This name is already in an active network vote. Your request joins it as another contender."; + +/* Username marketplace */ +"This name is in an active network vote and can't be traded until the contest ends." = "This name is in an active network vote and can't be traded until the contest ends."; + +/* Username marketplace: detail for an unregistered name */ +"This name is not registered." = "This name is not registered."; + +/* Username marketplace: seller-clarity callout on the detail sheet */ +"This name is offered by an independent user on the Dash network β€” not by Dash or this app. The seller sets the price." = "This name is offered by an independent user on the Dash network β€” not by Dash or this app. The seller sets the price."; + +/* Username marketplace: listing price exceeds the representable maximum */ +"This price is too high to list." = "This price is too high to list."; + /* Asset-lock retry: unsupported funding route */ "This transfer can't be retried from here." = "This transfer can't be retried from here."; +/* Username marketplace */ +"This username is not for sale." = "This username is not for sale."; + +/* Username marketplace */ +"This username's record couldn't be read from the network." = "This username's record couldn't be read from the network."; + /* Asset-lock retry: identity top-up with no identity */ "This wallet has no identity to top up." = "This wallet has no identity to top up."; @@ -2395,9 +2590,27 @@ /* Identity top-up sheet β€” title */ "Top Up Identity Balance" = "Top Up Identity Balance"; +/* Username marketplace: timeline card title */ +"Trade history" = "Trade history"; + /* Asset-lock retry finished */ "Transfer completed" = "Transfer completed"; +/* Username marketplace */ +"Transfer to Another Identity" = "Transfer to Another Identity"; + +/* Username marketplace: transfer sheet title */ +"Transfer β€œ%@”" = "Transfer β€œ%@”"; + +/* Username marketplace: history β€” transfer with both identities */ +"Transferred from %1$@ to %2$@" = "Transferred from %1$@ to %2$@"; + +/* Username marketplace: departed-name line β€” recipient identity */ +"Transferred to %@" = "Transferred to %@"; + +/* Username marketplace */ +"Username Marketplace" = "Username Marketplace"; + /* DashPay intro: feature title */ "Usernames, not addresses" = "Usernames, not addresses"; @@ -2422,12 +2635,24 @@ /* DashPay FAQ */ "Why do I need to enable it?" = "Why do I need to enable it?"; +/* Username marketplace: history participant is the current user */ +"you" = "you"; + +/* Username marketplace: contested request already submitted by this identity */ +"You already requested this name β€” the network vote is in progress. You'll find it under My Names." = "You already requested this name β€” the network vote is in progress. You'll find it under My Names."; + +/* Username marketplace: purchase confirmation body with seller clarity */ +"You are buying from an independent user, not from Dash or this app. The price plus a small network fee is paid from your identity balance, and the name moves to your identity immediately." = "You are buying from an independent user, not from Dash or this app. The price plus a small network fee is paid from your identity balance, and the name moves to your identity immediately."; + /* DashPay: body of the enable success sheet */ "You're all set. Other users can now send you contact requests β€” and you can send yours." = "You're all set. Other users can now send you contact requests β€” and you can send yours."; /* DashPay intro: feature title */ "Your history, organized" = "Your history, organized"; +/* Username marketplace */ +"Your identity balance can't cover this purchase: %@ DASH needed (including a fee reserve), %@ DASH available. Top up your identity balance and try again." = "Your identity balance can't cover this purchase: %1$@ DASH needed (including a fee reserve), %2$@ DASH available. Top up your identity balance and try again."; + /* DashPay FAQ */ "Your identity needs two extra keys so contact requests can be encrypted between you and other users. Enabling adds them with a single network transaction. This is a one time event." = "Your identity needs two extra keys so contact requests can be encrypted between you and other users. Enabling adds them with a single network transaction. This is a one time event."; @@ -4901,6 +5126,12 @@ /* Balance info sheet */ "Your private balance. Amounts and addresses are encrypted on-chain, so your holdings and history stay confidential." = "Your private balance. Amounts and addresses are encrypted on-chain, so your holdings and history stay confidential."; +/* 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";