diff --git a/DashWallet/Sources/AppleWatch/DWPhoneWCSessionManager.m b/DashWallet/Sources/AppleWatch/DWPhoneWCSessionManager.m index 395021d5f..3a25c374d 100644 --- a/DashWallet/Sources/AppleWatch/DWPhoneWCSessionManager.m +++ b/DashWallet/Sources/AppleWatch/DWPhoneWCSessionManager.m @@ -41,6 +41,11 @@ @interface DWPhoneWCSessionManager () [MarketplaceEvent] { + try await eventsPage(documentType: "priceUpdate", beforeMs: beforeMs, limit: limit) + } + + /// Newest-first page of DPNS purchase events, with price paid and + /// both counterparties. + func recentPurchases(beforeMs: UInt64?, limit: UInt32 = 25) async throws -> [MarketplaceEvent] { + try await eventsPage(documentType: "purchase", beforeMs: beforeMs, limit: limit) + } + + private func eventsPage(documentType: String, beforeMs: UInt64?, limit: UInt32) async throws -> [MarketplaceEvent] { + guard let sdk = SwiftDashSDKHost.shared.sdk else { return [] } + var conditions = "[[\"dataContractId\",\"==\",\"\(DPNSVotePoll.contractId)\"]" + if let beforeMs { + // "<=", not "<": several events can share one block's + // $createdAt, and a strict cursor at a page boundary would + // silently drop the rest of that timestamp group. The + // deliberate overlap re-fetches the boundary rows; callers + // dedupe on `eventIdBase58`. + conditions += ",[\"$createdAt\",\"<=\",\(beforeMs)]" + } + conditions += "]" + let result = try await sdk.documentList( + dataContractId: Self.documentHistoryContractId, + documentType: documentType, + whereClause: conditions, + // Order-by tuples here are [field, ascending-bool] — the FFI + // rejects "asc"/"desc" strings. false = newest first. + orderByClause: "[[\"$createdAt\",false]]", + limit: limit) + let docs: [[String: Any]] + if let array = result["documents"] as? [[String: Any]] { + docs = array + } else if let array = result["items"] as? [[String: Any]] { + docs = array + } else { + docs = result.values.compactMap { $0 as? [String: Any] } + } + return docs.compactMap { doc in + guard let rawEventId = doc["$id"] as? String, + let eventId = Self.identifier32(rawEventId), + let raw = doc["documentId"] as? String, + let documentId = Self.identifier32(raw), + let price = (doc["price"] as? NSNumber)?.uint64Value, + let createdAt = (doc["$createdAt"] as? NSNumber)?.uint64Value else { return nil } + let buyer = (doc["$ownerId"] as? String).flatMap(Self.identifier32) + let seller = (doc["sellerId"] as? String).flatMap(Self.identifier32) + return MarketplaceEvent( + eventIdBase58: eventId.toBase58String(), + dpnsDocumentIdBase58: documentId.toBase58String(), + priceCredits: price, + createdAtMs: createdAt, + buyerIdBase58: documentType == "purchase" ? buyer?.toBase58String() : nil, + sellerIdBase58: documentType == "purchase" ? seller?.toBase58String() : nil) + } + } + + /// Identifier-typed CUSTOM properties serialize as base64 in this + /// query path's JSON, while SYSTEM fields ($id, $ownerId) are base58. + /// Accept either, but only an exact 32-byte identifier — anything + /// else is nil, never a guess. + private static func identifier32(_ string: String) -> Data? { + if let data = Data(base64Encoded: string), data.count == 32 { + return data + } + if let data = Data.identifier(fromBase58: string), data.count == 32 { + return data + } + return nil + } + + /// Live DPNS state for a BATCH of domain document ids — one + /// `documentList` on the primary `$id` index (`in` clause) instead of + /// two round trips per name. The domain document itself carries the + /// entire live state a feed row claims about the present: label, + /// owner, and current `$price` (nil = not for sale). Ids absent from + /// the result no longer exist. + struct LiveDomainName { + let documentIdBase58: String + let label: String + let ownerId: Data + let priceCredits: UInt64? + + var isForSale: Bool { priceCredits != nil } + var priceDuffs: UInt64? { priceCredits.map { $0 / 1_000 } } + } + + func liveDomainNames(forDocumentIds ids: [String]) async throws -> [String: LiveDomainName] { + guard let sdk = SwiftDashSDKHost.shared.sdk, !ids.isEmpty else { return [:] } + let idList = ids.map { "\"\($0)\"" }.joined(separator: ",") + let result = try await sdk.documentList( + dataContractId: DPNSVotePoll.contractId, + documentType: DPNSVotePoll.documentTypeName, + whereClause: "[[\"$id\",\"in\",[\(idList)]]]", + orderByClause: "[[\"$id\",true]]", + limit: UInt32(ids.count)) + let docs: [[String: Any]] + if let array = result["documents"] as? [[String: Any]] { + docs = array + } else if let array = result["items"] as? [[String: Any]] { + docs = array + } else { + docs = result.values.compactMap { $0 as? [String: Any] } + } + var out: [String: LiveDomainName] = [:] + for doc in docs { + guard let rawId = doc["$id"] as? String, + let id = Self.identifier32(rawId), + let rawOwnerId = doc["$ownerId"] as? String, + let ownerId = Self.identifier32(rawOwnerId), + let label = (doc["label"] as? String) ?? (doc["normalizedLabel"] as? String) else { continue } + // Canonical base58 key — must byte-match the event side's + // dpnsDocumentIdBase58, which is produced the same way. + let documentIdBase58 = id.toBase58String() + out[documentIdBase58] = LiveDomainName( + documentIdBase58: documentIdBase58, + label: label, + ownerId: ownerId, + priceCredits: (doc["$price"] as? NSNumber)?.uint64Value) + } + return out + } + /// 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. diff --git a/DashWallet/Sources/Models/Coinbase/Coinbase.swift b/DashWallet/Sources/Models/Coinbase/Coinbase.swift index 239e4c508..f961e6502 100644 --- a/DashWallet/Sources/Models/Coinbase/Coinbase.swift +++ b/DashWallet/Sources/Models/Coinbase/Coinbase.swift @@ -359,12 +359,28 @@ struct CoinbaseWalletTransactionSnapshot { let walletId: Data let transactions: [CoinbaseWalletTransactionRecord] - static func current() -> CoinbaseWalletTransactionSnapshot? { - guard let snapshot = SwiftDashSDKWalletSource.fetchCurrentWalletSnapshot() else { - return nil - } + /// Only the wallet transactions whose wire-order txid is in `txids` + /// (the `TransactionMetadata.txHash` key convention) — point lookups, + /// so resolving stored metadata never materializes the whole wallet. + static func matching(txids: Set) -> CoinbaseWalletTransactionSnapshot? { + SwiftDashSDKWalletSource.fetch(txids: txids).map(Self.init(snapshot:)) + } - return CoinbaseWalletTransactionSnapshot( + /// Only the wallet transactions first seen at/after `cutoff`, for + /// matchers that are time-bounded anyway (the pending-receive resolver + /// requires `timestamp >= minimumTimestamp`). Callers pad the cutoff + /// for the firstSeen-vs-display-date skew. + static func recent(since cutoff: Date) -> CoinbaseWalletTransactionSnapshot? { + SwiftDashSDKWalletSource.fetchRecent(firstSeenSince: cutoff).map(Self.init(snapshot:)) + } +} + +// The projection init lives in an extension so the struct keeps its +// synthesized memberwise initializer (the resolver tests build snapshots +// with it directly). +extension CoinbaseWalletTransactionSnapshot { + fileprivate init(snapshot: SwiftDashSDKWalletTransactionSnapshot) { + self.init( walletId: snapshot.walletId, transactions: snapshot.transactions.map(CoinbaseWalletTransactionRecord.init(transaction:))) } @@ -511,8 +527,13 @@ final class CoinbaseTransactionMetadataTagger { } private func resolvePendingReceiveTransfersOnQueue() { - guard !pendingReceiveTransfers.isEmpty, - let snapshot = CoinbaseWalletTransactionSnapshot.current() else { + guard !pendingReceiveTransfers.isEmpty else { return } + // The resolver only accepts rows at/after each transfer's + // `minimumTimestamp`; fetch from the oldest one, padded a day for + // the firstSeen-vs-display-date skew, instead of the whole wallet. + let oldestMinimum = pendingReceiveTransfers.map(\.minimumTimestamp).min() ?? 0 + let cutoff = Date(timeIntervalSince1970: max(0, oldestMinimum - 86_400)) + guard let snapshot = CoinbaseWalletTransactionSnapshot.recent(since: cutoff) else { return } diff --git a/DashWallet/Sources/Models/Swap/SwapBuyTransactionMatcher.swift b/DashWallet/Sources/Models/Swap/SwapBuyTransactionMatcher.swift index 8c4846910..d2e89b0b9 100644 --- a/DashWallet/Sources/Models/Swap/SwapBuyTransactionMatcher.swift +++ b/DashWallet/Sources/Models/Swap/SwapBuyTransactionMatcher.swift @@ -35,6 +35,18 @@ enum SwapBuyTransactionMatcher { /// still far tighter than the address + amount checks, which do the real disambiguation. private static let timestampSlack: TimeInterval = 2 * 60 * 60 + /// The `firstSeen` fetch cutoff for matching `order`: order time minus + /// `timestampSlack` (the matcher's own below-order-time allowance on the + /// display date) minus a day for the firstSeen-vs-display-date skew + /// (block timestamps trail the wall clock; restores re-stamp old rows). + /// Callers hand this to `SwiftDashSDKWalletSource.fetchRecent(firstSeenSince:)` + /// so the matcher's candidate pool is a ranged index scan, not the + /// wallet's full history. + static func fetchCutoff(for order: SwapOrder) -> Date { + let orderTimestamp = TimeInterval(order.timestamp) / 1000.0 + return Date(timeIntervalSince1970: max(0, orderTimestamp - timestampSlack - 24 * 60 * 60)) + } + static func matchedTransaction( for order: SwapOrder, in transactions: [Transaction] diff --git a/DashWallet/Sources/Models/Swap/SwapTrackingService.swift b/DashWallet/Sources/Models/Swap/SwapTrackingService.swift index 6654846f6..ca80d6d7d 100644 --- a/DashWallet/Sources/Models/Swap/SwapTrackingService.swift +++ b/DashWallet/Sources/Models/Swap/SwapTrackingService.swift @@ -213,7 +213,11 @@ final class SwapTrackingService { private func completedWalletTxHash(for order: SwapOrder) -> String? { // Read the wallet's transactions from SwiftDashSDK; DashSync's allTransactions is frozen // (empty) post-migration, so a buy's incoming DASH would never match. - let transactions = SwiftDashSDKWalletSource.fetchAll() + // The matcher only considers rows around the order's own time, so + // range the fetch by `firstSeen` instead of walking the wallet. + let transactions = SwiftDashSDKWalletSource + .fetchRecent(firstSeenSince: SwapBuyTransactionMatcher.fetchCutoff(for: order))? + .transactions ?? [] return SwapBuyTransactionMatcher.walletTxHashHexString(for: order, in: transactions) } } diff --git a/DashWallet/Sources/UI/Explore Dash/UsernameMarketplaceScreen.swift b/DashWallet/Sources/UI/Explore Dash/UsernameMarketplaceScreen.swift index 790eaffb9..ed6c5d2b9 100644 --- a/DashWallet/Sources/UI/Explore Dash/UsernameMarketplaceScreen.swift +++ b/DashWallet/Sources/UI/Explore Dash/UsernameMarketplaceScreen.swift @@ -34,15 +34,38 @@ final class UsernameMarketplaceViewModel: ObservableObject { enum Segment: Int, CaseIterable { case find case mine + case browse 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") + case .browse: return NSLocalizedString("Browse", comment: "Username marketplace: browse-for-sale segment") } } } + enum BrowseFeed: Int, CaseIterable { + case priceChanges + case purchases + + var title: String { + switch self { + case .priceChanges: return NSLocalizedString("Price changes", comment: "Username marketplace: browse feed of recent listings and re-prices") + case .purchases: return NSLocalizedString("Purchases", comment: "Username marketplace: browse feed of recent sales") + } + } + } + + /// One rendered browse-feed row: the historical event plus the + /// name's label and live state (from the batched domain read). + struct BrowseEventRow: Identifiable { + let id: String + let label: String + let event: UsernameMarketplaceService.MarketplaceEvent + let live: UsernameMarketplaceService.LiveDomainName? + } + @Published var segment: Segment = .find @Published var query = "" @Published var searchResults: [DpnsMarketplaceName] = [] @@ -73,6 +96,155 @@ final class UsernameMarketplaceViewModel: ObservableObject { /// claim "Available" for a name already being contested. @Published var queryContest: UsernameMarketplaceService.ContestPrecheck? + // MARK: Browse (recent activity) state + // + // `$price` is not indexable on Dash Platform, so no query at any + // layer can order names by price. What the document-history system + // contract DOES index is RECENCY ([dataContractId, $createdAt]) — + // so Browse is an honest activity feed: recent price changes + // (listings / re-prices) and recent purchases, newest first. Each + // event's price and time are historical facts shown as such; each + // row also resolves the name's LIVE state for what's true now. + + @Published var browseFeed: BrowseFeed = .priceChanges + @Published var browsePriceChanges: [BrowseEventRow] = [] + @Published var browsePurchases: [BrowseEventRow] = [] + @Published var isBrowseLoading = false + @Published var browsePriceChangesExhausted = false + @Published var browsePurchasesExhausted = false + + /// Per-feed pagination cursors: oldest $createdAt fetched so far. + /// The service pages with "<=" (a timestamp can span many events), + /// so pages overlap at the boundary; `browseSeenEventIds` dedupes. + private var priceChangesCursorMs: UInt64? + private var purchasesCursorMs: UInt64? + private var browseSeenEventIds: Set = [] + /// documentId → live domain state, shared across both feeds and + /// filled by ONE batched read per page — a page costs exactly two + /// platform queries (events + batched $id lookup) regardless of size. + private var browseNameCache: [String: UsernameMarketplaceService.LiveDomainName] = [:] + /// In-flight load — cancelled by a reset so pull-to-refresh can + /// always restart, never silently bounce off the busy guard. The + /// generation counter keeps a cancelled task's cleanup from clearing + /// the loading flag of the load that replaced it. + private var browseTask: Task? + private var browseLoadGeneration = 0 + private static let browseEventsPerPage: UInt32 = 25 + + var browseRows: [BrowseEventRow] { + browseFeed == .priceChanges ? browsePriceChanges : browsePurchases + } + + var browseFeedExhausted: Bool { + browseFeed == .priceChanges ? browsePriceChangesExhausted : browsePurchasesExhausted + } + + /// Load the next page of the CURRENT feed. `reset` cancels any + /// in-flight load and restarts both feeds from the newest event + /// (pull-to-refresh) so live states and prices re-read fresh. + func loadBrowse(reset: Bool = false) { + if reset { + browseTask?.cancel() + browsePriceChanges = [] + browsePurchases = [] + priceChangesCursorMs = nil + purchasesCursorMs = nil + browsePriceChangesExhausted = false + browsePurchasesExhausted = false + browseSeenEventIds = [] + browseNameCache = [:] + } else { + guard !isBrowseLoading else { return } + } + let feed = browseFeed + guard !browseFeedExhausted else { return } + isBrowseLoading = true + browseLoadGeneration += 1 + let generation = browseLoadGeneration + browseTask = Task { [weak self] in + guard let self else { return } + defer { + if self.browseLoadGeneration == generation { + self.isBrowseLoading = false + } + } + do { + // Price changes is a FOR-SALE feed: rows whose name was + // since delisted or sold are dropped, and each name + // renders once (its newest event — whose price, by + // consensus, IS the live price of a still-listed name). + // Dropped rows can leave a page thin, so keep paging + // within a bounded pass until something showed. + var pagesLeft = feed == .priceChanges ? 4 : 1 + while pagesLeft > 0 { + pagesLeft -= 1 + let cursor = feed == .priceChanges ? priceChangesCursorMs : purchasesCursorMs + let rawEvents = feed == .priceChanges + ? try await service.recentPriceChanges(beforeMs: cursor, limit: Self.browseEventsPerPage) + : try await service.recentPurchases(beforeMs: cursor, limit: Self.browseEventsPerPage) + guard !Task.isCancelled else { return } + // Boundary rows reappear by design (the "<=" cursor); + // the event-id set drops what was already consumed. + let events = rawEvents.filter { !browseSeenEventIds.contains($0.eventIdBase58) } + events.forEach { browseSeenEventIds.insert($0.eventIdBase58) } + // One batched $id lookup covers every name this page + // mentions that we haven't resolved yet. + let unknownIds = Array(Set(events.map(\.dpnsDocumentIdBase58) + .filter { browseNameCache[$0] == nil })) + if !unknownIds.isEmpty { + for (id, live) in try await service.liveDomainNames(forDocumentIds: unknownIds) { + browseNameCache[id] = live + } + } + guard !Task.isCancelled else { return } + var rows: [BrowseEventRow] = [] + for event in events { + // Absent from the batch = the document is gone. + guard let live = browseNameCache[event.dpnsDocumentIdBase58] else { continue } + let row = BrowseEventRow( + id: event.eventIdBase58, + label: live.label, + event: event, + live: live) + if feed == .priceChanges { + guard live.isForSale, + !browsePriceChanges.contains(where: { $0.event.dpnsDocumentIdBase58 == event.dpnsDocumentIdBase58 }), + !rows.contains(where: { $0.event.dpnsDocumentIdBase58 == event.dpnsDocumentIdBase58 }) + else { continue } + } + rows.append(row) + } + // Exhaustion reads the RAW page: a short page means the + // trail ended. A full page of only-seen rows means the + // cursor cannot advance (>page-size events sharing one + // timestamp) — stop rather than spin. + let exhausted = rawEvents.count < Int(Self.browseEventsPerPage) + || (events.isEmpty && rawEvents.count == Int(Self.browseEventsPerPage)) + if feed == .priceChanges { + browsePriceChanges.append(contentsOf: rows) + priceChangesCursorMs = rawEvents.last?.createdAtMs ?? cursor + if exhausted { browsePriceChangesExhausted = true } + } else { + browsePurchases.append(contentsOf: rows) + purchasesCursorMs = rawEvents.last?.createdAtMs ?? cursor + if exhausted { browsePurchasesExhausted = true } + } + if exhausted || !rows.isEmpty { break } + } + } catch { + guard !Task.isCancelled else { return } + errorMessage = UsernameMarketplaceService.userFacingMessage(for: error) + } + } + } + + /// Awaitable refresh for `.refreshable` — the spinner stays until + /// the restarted load actually finishes. + func refreshBrowse() async { + loadBrowse(reset: true) + await browseTask?.value + } + let service = UsernameMarketplaceService() private var searchTask: Task? @@ -293,6 +465,8 @@ struct UsernameMarketplaceScreen: View { findSection case .mine: mySection + case .browse: + browseSection } Spacer(minLength: 0) @@ -394,6 +568,140 @@ struct UsernameMarketplaceScreen: View { } } + // MARK: Browse segment + + /// Recent marketplace activity, newest first: price changes + /// (listings / re-prices) and purchases, straight off the + /// document-history trail's [dataContractId, $createdAt] index. + /// Event price and time are historical facts; the trailing badge is + /// the name's LIVE state, so a stale listing can't read as an offer. + private var browseSection: some View { + VStack(alignment: .leading, spacing: 0) { + Picker("", selection: $viewModel.browseFeed) { + ForEach(UsernameMarketplaceViewModel.BrowseFeed.allCases, id: \.self) { feed in + Text(feed.title).tag(feed) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, 20) + .padding(.bottom, 8) + .onChange(of: viewModel.browseFeed) { _, _ in + if viewModel.browseRows.isEmpty { + viewModel.loadBrowse() + } + } + + ScrollView { + LazyVStack(spacing: 6) { + ForEach(viewModel.browseRows) { row in + browseEventRow(row) + } + if viewModel.browseRows.isEmpty && !viewModel.isBrowseLoading { + emptyHint(viewModel.browseFeed == .priceChanges + ? NSLocalizedString("No names are for sale in the recent listing activity. Show more reaches further back.", comment: "Username marketplace: price-changes feed found no live listings yet") + : NSLocalizedString("No purchases on the network yet.", comment: "Username marketplace: empty purchases feed")) + } + if viewModel.isBrowseLoading { + HStack(spacing: 8) { + SwiftUI.ProgressView() + Text(NSLocalizedString("Loading activity…", comment: "Username marketplace: browse feed loading")) + .font(.system(size: 12)) + .foregroundColor(.dash.secondaryText) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 14) + } else if !viewModel.browseFeedExhausted && !viewModel.browseRows.isEmpty { + Button { + viewModel.loadBrowse() + } label: { + Text(NSLocalizedString("Show more", comment: "Username marketplace: load older browse activity")) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.dash.blue) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 15) + .padding(.top, 4) + .padding(.bottom, 24) + } + .refreshable { + await viewModel.refreshBrowse() + } + } + .onAppear { + if viewModel.browseRows.isEmpty { + viewModel.loadBrowse() + } + } + } + + private func browseEventRow(_ row: UsernameMarketplaceViewModel.BrowseEventRow) -> some View { + let eventDash = (row.event.priceCredits / 1000).dashAmount.formattedDashAmountWithoutCurrencySymbol + let eventDate = DWDateFormatter.sharedInstance.shortStringFromDate( + Date(timeIntervalSince1970: Double(row.event.createdAtMs) / 1000)) + let subtitle: String + if viewModel.browseFeed == .priceChanges { + subtitle = String.localizedStringWithFormat( + NSLocalizedString("Listed for %1$@ DASH · %2$@", comment: "Username marketplace: price-change feed row — event price, then date"), + eventDash, eventDate) + } else if let seller = row.event.sellerIdBase58, let buyer = row.event.buyerIdBase58 { + subtitle = String.localizedStringWithFormat( + NSLocalizedString("Sold for %1$@ DASH · %2$@ · %3$@ → %4$@", comment: "Username marketplace: purchases feed row — price paid, date, then seller → buyer short ids"), + eventDash, eventDate, shortBase58(seller), shortBase58(buyer)) + } else { + subtitle = String.localizedStringWithFormat( + NSLocalizedString("Sold for %1$@ DASH · %2$@", comment: "Username marketplace: purchases feed row — price paid, then date"), + eventDash, eventDate) + } + return Button { + selectedLabel = SelectedMarketplaceLabel(label: row.label) + } label: { + HStack(spacing: 10) { + ContactAvatarView( + title: row.label, + avatarURL: nil, + identitySeed: row.live?.ownerId ?? Data()) + VStack(alignment: .leading, spacing: 2) { + Text(row.label) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.dash.primaryText) + .lineLimit(1) + Text(subtitle) + .font(.system(size: 11)) + .foregroundColor(.dash.tertiaryText) + .lineLimit(1) + } + Spacer() + if let live = row.live, live.isForSale, let priceDuffs = live.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) + } + } else { + Text(NSLocalizedString("Not for sale now", comment: "Username marketplace: browse row whose name is no longer listed")) + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.dash.tertiaryText) + } + 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) + } + // MARK: My Names segment private var mySection: some View { @@ -661,6 +969,10 @@ struct UsernameMarketplaceScreen: View { .buttonStyle(.plain) } + private func shortBase58(_ base58: String) -> String { + String(base58.prefix(8)) + "…" + String(base58.suffix(4)) + } + private func sectionHeader(_ text: String) -> some View { HStack { Text(text) diff --git a/DashWallet/Sources/UI/Home/Tx Metadata/CoinbaseMetadataProvider.swift b/DashWallet/Sources/UI/Home/Tx Metadata/CoinbaseMetadataProvider.swift index 4e4fde3cf..3d8321d74 100644 --- a/DashWallet/Sources/UI/Home/Tx Metadata/CoinbaseMetadataProvider.swift +++ b/DashWallet/Sources/UI/Home/Tx Metadata/CoinbaseMetadataProvider.swift @@ -35,10 +35,18 @@ final class CoinbaseMetadataProvider: MetadataProvider, @unchecked Sendable { let metadataUpdated = PassthroughSubject() + /// Single-flight guard for `refreshMetadata()` — both fields guarded by + /// `metadataQueue`. Every trigger (SwiftData save, wallet switch, DAO + /// change) funnels through `requestRefresh()`; requests landing while a + /// refresh runs collapse into one trailing rerun. Without this, + /// save-storms during initial sync queue unbounded concurrent refreshes + /// (this pegged 7 cores and 4.6GB after a mainnet recovery back when + /// each refresh also fetched the full wallet snapshot). + private var refreshInFlight = false + private var refreshQueued = false + private init() { - Task { - await refreshMetadata() - } + requestRefresh() metadataDao.$lastChange .receive(on: DispatchQueue.main) @@ -46,10 +54,8 @@ final class CoinbaseMetadataProvider: MetadataProvider, @unchecked Sendable { guard let self, let change = change else { return } switch change { - case .created(let metadata), .updated(let metadata, _): - Task { - await self.refreshMetadata() - } + case .created, .updated: + self.requestRefresh() case .deleted(let metadata): metadataQueue.sync { @@ -74,9 +80,7 @@ final class CoinbaseMetadataProvider: MetadataProvider, @unchecked Sendable { .receive(on: DispatchQueue.main) .throttle(for: .seconds(1), scheduler: DispatchQueue.main, latest: true) .sink { [weak self] _ in - Task { - await self?.refreshMetadata() - } + self?.requestRefresh() } .store(in: &cancellableBag) @@ -84,19 +88,56 @@ final class CoinbaseMetadataProvider: MetadataProvider, @unchecked Sendable { for: SwiftDashSDKWalletState.activeWalletDidChangeNotification) .receive(on: DispatchQueue.main) .sink { [weak self] _ in - Task { - await self?.refreshMetadata() - } + self?.requestRefresh() } .store(in: &cancellableBag) } + /// Coalescing entry point for all refresh triggers — see the guard + /// fields' doc for why triggers must not spawn refreshes directly. + private func requestRefresh() { + let shouldStart: Bool = metadataQueue.sync { + if refreshInFlight { + refreshQueued = true + return false + } + refreshInFlight = true + return true + } + guard shouldStart else { return } + Task { [weak self] in + guard let self else { return } + var runAgain = true + while runAgain { + await self.refreshMetadata() + runAgain = self.metadataQueue.sync { + if self.refreshQueued { + self.refreshQueued = false + return true + } + self.refreshInFlight = false + return false + } + } + } + } + private func refreshMetadata() async { let coinbaseMetadata = metadataDao.all().filter { $0.service == ServiceName.coinbase.rawValue } - let current = Self.resolveMetadata( - storedMetadata: coinbaseMetadata, - walletSnapshot: CoinbaseWalletTransactionSnapshot.current(), - icon: coinbaseIcon()) + // Resolve against only the rows the stored metadata points at — + // point lookups on the txid index. With no stored Coinbase metadata + // (most wallets, and every save tick during initial sync) the + // refresh doesn't touch the transaction store at all. + let current: [Data: TxRowMetadata] + if coinbaseMetadata.isEmpty { + current = [:] + } else { + current = Self.resolveMetadata( + storedMetadata: coinbaseMetadata, + walletSnapshot: CoinbaseWalletTransactionSnapshot.matching( + txids: Set(coinbaseMetadata.map(\.txHash))), + icon: coinbaseIcon()) + } metadataQueue.async { [weak self] in guard let self else { return } diff --git a/DashWallet/Sources/UI/Home/Tx Metadata/SwapOrderMetadataProvider.swift b/DashWallet/Sources/UI/Home/Tx Metadata/SwapOrderMetadataProvider.swift index 9dd676484..84e40acb3 100644 --- a/DashWallet/Sources/UI/Home/Tx Metadata/SwapOrderMetadataProvider.swift +++ b/DashWallet/Sources/UI/Home/Tx Metadata/SwapOrderMetadataProvider.swift @@ -65,9 +65,13 @@ class SwapOrderMetadataProvider: MetadataProvider, @unchecked Sendable { // MARK: - Private private func updateMetadata(from orders: [SwapOrder]) { + // One shared, `firstSeen`-ranged fetch feeds every order that needs + // the address+time buy matcher (the previous shape walked the ENTIRE + // wallet once per order, on every balance tick). + let matcherTransactions = buyMatcherTransactions(for: orders) var current: [Data: TxRowMetadata] = [:] for order in orders { - if let key = metadataKey(for: order) { + if let key = metadataKey(for: order, matcherTransactions: matcherTransactions) { current[key] = makeMetadata(for: order) } } @@ -85,28 +89,35 @@ class SwapOrderMetadataProvider: MetadataProvider, @unchecked Sendable { } } - private func metadataKey(for order: SwapOrder) -> Data? { + private func metadataKey(for order: SwapOrder, matcherTransactions: [Transaction]) -> Data? { if order.direction == "sell" { return Data(hex: order.id).map { Data($0.reversed()) } } else { + // `outboundTxHash` is display-order hex; the row lives under its + // wire-order reversal — a point lookup on the txid index (the + // previous shape scanned the whole wallet for the hex match). if let outboundTxHash = order.outboundTxHash?.trimmingCharacters(in: .whitespacesAndNewlines), !outboundTxHash.isEmpty, let txHashData = Data(hex: outboundTxHash), - let matchingTx = SwiftDashSDKWalletSource.fetchAll().first( - where: { $0.txHashHexString.caseInsensitiveCompare(outboundTxHash) == .orderedSame } - ), + let matchingTx = SwiftDashSDKWalletSource.fetch(txid: Data(txHashData.reversed())), SwapBuyTransactionMatcher.matchedTransaction(for: order, in: [matchingTx]) != nil { return Data(txHashData.reversed()) } - return walletTxHashData(for: order) + return SwapBuyTransactionMatcher.walletTxHashData(for: order, in: matcherTransactions) } } - private func walletTxHashData(for order: SwapOrder) -> Data? { - // SwiftDashSDK tx set; DashSync's allTransactions is frozen (empty) post-migration. - let transactions = SwiftDashSDKWalletSource.fetchAll() - return SwapBuyTransactionMatcher.walletTxHashData(for: order, in: transactions) + /// Candidate pool for the buy matcher: wallet transactions first seen at/ + /// after the oldest buy order's fetch cutoff. Empty (and fetch-free) when + /// no order needs matching. SwiftDashSDK tx set; DashSync's + /// allTransactions is frozen (empty) post-migration. + private func buyMatcherTransactions(for orders: [SwapOrder]) -> [Transaction] { + let cutoffs = orders + .filter { $0.direction != "sell" } + .map(SwapBuyTransactionMatcher.fetchCutoff(for:)) + guard let oldest = cutoffs.min() else { return [] } + return SwiftDashSDKWalletSource.fetchRecent(firstSeenSince: oldest)?.transactions ?? [] } private func refreshMetadata() { diff --git a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift index c8935b28d..9bae50603 100644 --- a/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift +++ b/DashWallet/Sources/UI/Home/Views/HomeViewModel.swift @@ -1316,21 +1316,26 @@ final class DWAppleWatchSnapshotProvider: NSObject { @objc static func hasWallet() -> Bool { - SwiftDashSDKWalletSource.fetchCurrentWalletSnapshot() != nil + SwiftDashSDKWalletSource.hasActiveWallet } /// The legacy bridge sent at most the account's 100 newest Core /// transactions. Keep that limit and ordering so existing watches receive /// the same archive shape and list semantics. + /// + /// The page is picked by `firstSeen` (the store's indexed timeline) and + /// then displayed in `date` order like before; the two only diverge by + /// the mempool→block timestamp skew, and never inside a 100-row window + /// that matters to a watch face. Scoping the fetch is what keeps a + /// context send from materializing a many-thousand-tx wallet. @objc static func recentTransactions() -> [DWAppleWatchTransactionSnapshot] { - guard let snapshot = SwiftDashSDKWalletSource.fetchCurrentWalletSnapshot() else { + guard let snapshot = SwiftDashSDKWalletSource.fetchRecent(limit: 100) else { return [] } return snapshot.transactions .sorted { $0.date > $1.date } - .prefix(100) .map(makeSnapshot) } @@ -1406,6 +1411,59 @@ class SwiftDashSDKWalletSource: TransactionSource { return fetchOne(txid: txid, in: ModelContext(container), walletId: walletId) } + /// The newest `limit` wallet transactions (`firstSeen` desc — the same + /// timeline the full snapshot is sorted by). Safe from any thread. + /// + /// Wallet-scoped in SQL against the `firstSeen` index (see + /// `scopedNewestFirst`), so callers that need a page of recent rows no + /// longer materialize the entire wallet the way + /// `fetchCurrentWalletSnapshot()` does. + static func fetchRecent(limit: Int) -> SwiftDashSDKWalletTransactionSnapshot? { + guard let (container, walletId) = hostHandles() else { return nil } + let transactions = scopedNewestFirst( + in: ModelContext(container), walletId: walletId, + minFirstSeen: 0, limit: limit) + return SwiftDashSDKWalletTransactionSnapshot(walletId: walletId, transactions: transactions) + } + + /// Wallet transactions first seen at/after `cutoff` (`firstSeen` desc). + /// Safe from any thread. + /// + /// `firstSeen` is the SDK's observation stamp (wall clock when the tx + /// enters the mempool; the block timestamp once mined/restored), while + /// `Transaction.date` prefers the block timestamp — so callers matching + /// on the display date must pad `cutoff` with generous slack for that + /// skew rather than pass an exact bound (e.g. + /// `SwapBuyTransactionMatcher.fetchCutoff(for:)`). + static func fetchRecent(firstSeenSince cutoff: Date) -> SwiftDashSDKWalletTransactionSnapshot? { + guard let (container, walletId) = hostHandles() else { return nil } + let transactions = scopedNewestFirst( + in: ModelContext(container), walletId: walletId, + minFirstSeen: UInt64(max(0, cutoff.timeIntervalSince1970)), limit: nil) + return SwiftDashSDKWalletTransactionSnapshot(walletId: walletId, transactions: transactions) + } + + /// The subset of wallet transactions whose txid (wire order) is in + /// `txids`, `firstSeen` desc. Safe from any thread. Point lookups on the + /// unique txid index — cost scales with `txids.count`, not with the + /// wallet's history size. + static func fetch(txids: Set) -> SwiftDashSDKWalletTransactionSnapshot? { + guard let (container, walletId) = hostHandles() else { return nil } + guard !txids.isEmpty else { + return SwiftDashSDKWalletTransactionSnapshot(walletId: walletId, transactions: []) + } + let context = ModelContext(container) + var descriptor = FetchDescriptor( + predicate: #Predicate { txids.contains($0.txid) }, + sortBy: [SortDescriptor(\.firstSeen, order: .reverse)]) + descriptor.relationshipKeyPathsForPrefetching = [\.outputs, \.inputs] + let rows = (try? context.fetch(descriptor)) ?? [] + let transactions = rows + .filter { isWalletMember($0, walletId: walletId) } + .map { wrap($0, walletId: walletId) } + return SwiftDashSDKWalletTransactionSnapshot(walletId: walletId, transactions: transactions) + } + /// The active wallet's shielded operations as history items, for /// interleaving with the Core rows. Safe from any thread. /// @@ -1847,6 +1905,11 @@ class SwiftDashSDKWalletSource: TransactionSource { return row.account?.wallet.walletId == walletId } + /// Whether an active wallet is configured — the same truth + /// `fetchCurrentWalletSnapshot() != nil` reports, without materializing + /// every wallet transaction to learn it. + static var hasActiveWallet: Bool { hostHandles() != nil } + /// The host is `@MainActor`-isolated; grab its container + active-wallet /// id in one brief main hop (two property reads — unlike the fetches, /// cheap enough to block a worker queue on). `ModelContainer` is @@ -1878,59 +1941,160 @@ class SwiftDashSDKWalletSource: TransactionSource { var descriptor = FetchDescriptor( predicate: #Predicate { $0.txid == txid }) descriptor.fetchLimit = 1 - guard let row = (try? context.fetch(descriptor))?.first else { + guard let row = (try? context.fetch(descriptor))?.first, + isWalletMember(row, walletId: walletId) else { return nil } - // Membership can arrive through either side of the SDK's documented - // union: wallet-scoped TXOs or an account's involved-transactions - // relation. Accept both so an out-of-order receipt is not discarded. - guard row.outputs.contains(where: { $0.walletId == walletId }) + return wrap(row, walletId: walletId) + } + + /// Membership can arrive through either side of the SDK's documented + /// union: wallet-scoped TXOs or an account's involved-transactions + /// relation. Accept both so an out-of-order receipt is not discarded. + /// Reads relationships — call on the row's fetch thread. + private static func isWalletMember(_ row: PersistentTransaction, walletId: Data) -> Bool { + row.outputs.contains(where: { $0.walletId == walletId }) || row.inputs.contains(where: { $0.walletId == walletId }) - || row.involvedAccounts.contains(where: { $0.wallet.walletId == walletId }) else { - return nil - } + || row.involvedAccounts.contains(where: { $0.wallet.walletId == walletId }) + } + + /// Wrap a fetched row on its fetch thread, stamping the CoinJoin-mixing + /// classification (cached — see `cachedIsCoinJoinMixingTx`). + private static func wrap(_ row: PersistentTransaction, walletId: Data) -> Transaction { let tx = Transaction(persistentTransaction: row, walletId: walletId) - tx.sdkCoinJoinMixing = isCoinJoinMixingTx(row) + tx.sdkCoinJoinMixing = cachedIsCoinJoinMixingTx(row) return tx } - /// Every txid the active wallet participates in. Union the canonical TXO - /// membership with `PersistentAccount.involvedTransactions`, as required - /// by the SDK model: the latter closes out-of-order/payload-only indexing - /// gaps where the transaction record is saved before its TXO relationship - /// is available to the home timeline. - private static func activeWalletTxids( + /// SQL-scoped timeline fetch: wallet membership is evaluated inside the + /// store (EXISTS subqueries over the indexed `PersistentTxo.walletId` + /// denorm and the `involvedAccounts` join) while scanning the `firstSeen` + /// index newest-first, so only the returned rows are ever materialized — + /// unlike the full-wallet pass, whose cost is the whole history no matter + /// how few rows the caller needs. + /// + /// If SwiftData fails to translate the membership predicate (an OS + /// regression, not a data state), the fetch throws and we fall back to + /// the full-wallet pass — same results, old cost — and log it so the + /// regression is visible instead of silent. + private static func scopedNewestFirst( in context: ModelContext, - walletId: Data - ) -> Set { - let txoDescriptor = FetchDescriptor( + walletId: Data, + minFirstSeen: UInt64, + limit: Int? + ) -> [Transaction] { + var descriptor = FetchDescriptor( + predicate: #Predicate { tx in + tx.firstSeen >= minFirstSeen + && (tx.outputs.contains(where: { $0.walletId == walletId }) + || tx.inputs.contains(where: { $0.walletId == walletId }) + || tx.involvedAccounts.contains(where: { $0.wallet.walletId == walletId })) + }, + sortBy: [SortDescriptor(\.firstSeen, order: .reverse)]) + if let limit { descriptor.fetchLimit = limit } + descriptor.relationshipKeyPathsForPrefetching = [\.outputs, \.inputs] + do { + return try context.fetch(descriptor).map { wrap($0, walletId: walletId) } + } catch { + DWLogger.log("SwiftDashSDKWalletSource: scoped fetch failed (\(error)); falling back to the full wallet pass") + return fetchAndWrap(in: context, walletId: walletId, minFirstSeen: minFirstSeen, limit: limit) + } + } + + /// Everything a wallet-wide wrap needs from the TXO table, computed in + /// one indexed scan with relationships prefetched: the member-txid union + /// AND the per-tx CoinJoin-account roles. Replaces the previous shape — + /// a txid walk plus a per-row `isCoinJoinMixingTx` traversal — whose + /// per-row relationship faults each cost a separate store round-trip + /// (multiple seconds of SwiftData CPU on a CoinJoin-heavy wallet). + private struct WalletTxRollup { + /// Every txid the wallet participates in: the canonical TXO union + /// plus `PersistentAccount.involvedTransactions` (payload-only + /// membership — see the SDK model doc on `PersistentTransaction`). + var txids: Set = [] + /// Txids classified as CoinJoin mixing operations — the same rules + /// as `isCoinJoinMixingTx`, evaluated from the wallet's own TXO + /// roles (matching DashSync's per-wallet-account grouping; another + /// on-device wallet's stake in a shared tx doesn't classify ours). + var mixingTxids: Set = [] + } + + private static func walletTxRollup(in context: ModelContext, walletId: Data) -> WalletTxRollup { + var txoDescriptor = FetchDescriptor( predicate: #Predicate { $0.walletId == walletId }) + txoDescriptor.relationshipKeyPathsForPrefetching = [ + \.transaction, \.spendingTransaction, \.coreAddress, \.account, + ] let txos = (try? context.fetch(txoDescriptor)) ?? [] - var txids = Set() + + var rollup = WalletTxRollup() + // The three classification ingredients (rules 1–3 of + // `isCoinJoinMixingTx`), accumulated per txid. + var kindOrDepositMixing: Set = [] + var spendsCoinJoin: Set = [] + var depositsToStandard: Set = [] for txo in txos { - if let producing = txo.transaction { txids.insert(producing.txid) } - if let spending = txo.spendingTransaction { txids.insert(spending.txid) } + let ownerType = ownerAccountType(txo) + if let producing = txo.transaction { + rollup.txids.insert(producing.txid) + if producing.typedKind == .coinJoin || ownerType == coinJoinAccountType { + kindOrDepositMixing.insert(producing.txid) + } + if ownerType == standardAccountType { + depositsToStandard.insert(producing.txid) + } + } + if let spending = txo.spendingTransaction { + rollup.txids.insert(spending.txid) + if spending.typedKind == .coinJoin { + kindOrDepositMixing.insert(spending.txid) + } + if ownerType == coinJoinAccountType { + spendsCoinJoin.insert(spending.txid) + } + } } + rollup.mixingTxids = kindOrDepositMixing + .union(spendsCoinJoin.subtracting(depositsToStandard)) + // Payload-only membership (e.g. a ProRegTx matched purely through + // its payload keys) is only representable via + // `PersistentAccount.involvedTransactions`; union it in, as the SDK + // model requires. Nearly every row here is already realized by the + // TXO prefetch above, so this walk no longer faults the store per tx. var walletDescriptor = FetchDescriptor( predicate: #Predicate { $0.walletId == walletId }) walletDescriptor.fetchLimit = 1 if let wallet = (try? context.fetch(walletDescriptor))?.first { for account in wallet.accounts { for transaction in account.involvedTransactions { - txids.insert(transaction.txid) + rollup.txids.insert(transaction.txid) + if transaction.typedKind == .coinJoin { + rollup.mixingTxids.insert(transaction.txid) + } } } } - return txids + return rollup } - private static func fetchAndWrap(in context: ModelContext, walletId: Data) -> [Transaction] { - let txids = activeWalletTxids(in: context, walletId: walletId) - guard !txids.isEmpty else { return [] } - let descriptor = FetchDescriptor( - predicate: #Predicate { txids.contains($0.txid) }, + private static func fetchAndWrap( + in context: ModelContext, + walletId: Data, + minFirstSeen: UInt64 = 0, + limit: Int? = nil + ) -> [Transaction] { + let rollup = walletTxRollup(in: context, walletId: walletId) + guard !rollup.txids.isEmpty else { return [] } + let txids = rollup.txids + var descriptor = FetchDescriptor( + predicate: #Predicate { txids.contains($0.txid) && $0.firstSeen >= minFirstSeen }, sortBy: [SortDescriptor(\.firstSeen, order: .reverse)]) + if let limit { descriptor.fetchLimit = limit } + // Prefetch what the wrap reads (`SDKSnapshot` walks `outputs` + + // `inputs`) — without this every wrapped row costs two more store + // round-trips. + descriptor.relationshipKeyPathsForPrefetching = [\.outputs, \.inputs] let rows: [PersistentTransaction] do { rows = try context.fetch(descriptor) @@ -1940,7 +2104,11 @@ class SwiftDashSDKWalletSource: TransactionSource { } return rows.map { row -> Transaction in let tx = Transaction(persistentTransaction: row, walletId: walletId) - tx.sdkCoinJoinMixing = Self.isCoinJoinMixingTx(row) + let isMixing = rollup.mixingTxids.contains(row.txid) + tx.sdkCoinJoinMixing = isMixing + // Seed the per-row cache so subsequent scoped fetches skip the + // relationship traversal for rows this pass already classified. + storeMixingClassification(txid: row.txid, stamp: row.lastUpdated, isMixing: isMixing) return tx } } @@ -1949,6 +2117,43 @@ class SwiftDashSDKWalletSource: TransactionSource { private static let coinJoinAccountType: UInt32 = 1 // 0=Standard(BIP44/BIP32), 1=CoinJoin private static let standardAccountType: UInt32 = 0 + /// CoinJoin-mixing classification cache for the per-row (scoped) fetch + /// paths, keyed by txid and stamped with the row's `lastUpdated` — the + /// persistence handler bumps that on every re-upsert, so an entry + /// self-invalidates the next time the SDK actually rewrites the row. + /// Guarded by `mixingCacheLock` (entries are written from whichever + /// thread fetched the row). Capacity-bounded: population tracks wallet + /// size, and blowing the bound just resets to a cold cache. + private static let mixingCacheLock = NSLock() + private static var mixingCache: [Data: (stamp: Date, isMixing: Bool)] = [:] + private static let mixingCacheCapacity = 20_000 + + private static func storeMixingClassification(txid: Data, stamp: Date, isMixing: Bool) { + mixingCacheLock.lock() + defer { mixingCacheLock.unlock() } + if mixingCache.count >= mixingCacheCapacity, mixingCache[txid] == nil { + mixingCache.removeAll(keepingCapacity: true) + } + mixingCache[txid] = (stamp, isMixing) + } + + /// Cached front for `isCoinJoinMixingTx` on the scoped fetch paths: a + /// hit skips the relationship traversal entirely; a miss computes and + /// seeds. Must run on the row's fetch thread (the compute path traverses + /// relationships). The full-wallet pass doesn't call this — it derives + /// every classification from its single TXO scan and seeds the cache. + private static func cachedIsCoinJoinMixingTx(_ row: PersistentTransaction) -> Bool { + let txid = row.txid + let stamp = row.lastUpdated + mixingCacheLock.lock() + let hit = mixingCache[txid] + mixingCacheLock.unlock() + if let hit, hit.stamp == stamp { return hit.isMixing } + let isMixing = isCoinJoinMixingTx(row) + storeMixingClassification(txid: txid, stamp: stamp, isMixing: isMixing) + return isMixing + } + /// Account type owning a TXO — canonical path is `coreAddress?.account`; /// `account` is the fallback used before the address row is linked. private static func ownerAccountType(_ txo: PersistentTxo) -> UInt32? { @@ -1957,7 +2162,10 @@ class SwiftDashSDKWalletSource: TransactionSource { /// CoinJoin mixing-operation detection. Traverses SwiftData relationships, /// so it must run on the thread that owns the row's `ModelContext` (the - /// fetch thread). DashSync grouped by CoinJoin-account *role*, not tx + /// fetch thread). Called per row only on scoped-fetch cache misses (see + /// `cachedIsCoinJoinMixingTx`); the full-wallet pass evaluates these same + /// rules set-wise in `walletTxRollup` — keep the two in lockstep. + /// DashSync grouped by CoinJoin-account *role*, not tx /// structure, so the SDK's structural `typedKind` (mixing rounds only) is /// too narrow. We classify a tx as a mixing operation when: /// 1. it DEPOSITS into the CoinJoin account (≥1 CoinJoin output) — covers diff --git a/DashWallet/en.lproj/Localizable.strings b/DashWallet/en.lproj/Localizable.strings index 6b3dfb7bb..cb38ac9b8 100644 --- a/DashWallet/en.lproj/Localizable.strings +++ b/DashWallet/en.lproj/Localizable.strings @@ -803,6 +803,39 @@ /* Username marketplace: activity overlay while the contested request transition runs */ "Submitting your username request…" = "Submitting your username request…"; +/* Username marketplace: browse-for-sale segment */ +"Browse" = "Browse"; + +/* Username marketplace: browse feed of recent listings and re-prices */ +"Price changes" = "Price changes"; + +/* Username marketplace: browse feed of recent sales */ +"Purchases" = "Purchases"; + +/* Username marketplace: price-change feed row — event price, then date */ +"Listed for %1$@ DASH · %2$@" = "Listed for %1$@ DASH · %2$@"; + +/* Username marketplace: purchases feed row — price paid, then date */ +"Sold for %1$@ DASH · %2$@" = "Sold for %1$@ DASH · %2$@"; + +/* Username marketplace: purchases feed row — price paid, date, then seller → buyer short ids */ +"Sold for %1$@ DASH · %2$@ · %3$@ → %4$@" = "Sold for %1$@ DASH · %2$@ · %3$@ → %4$@"; + +/* Username marketplace: browse row whose name is no longer listed */ +"Not for sale now" = "Not for sale now"; + +/* Username marketplace: price-changes feed found no live listings yet */ +"No names are for sale in the recent listing activity. Show more reaches further back." = "No names are for sale in the recent listing activity. Show more reaches further back."; + +/* Username marketplace: empty purchases feed */ +"No purchases on the network yet." = "No purchases on the network yet."; + +/* Username marketplace: browse feed loading */ +"Loading activity…" = "Loading activity…"; + +/* Username marketplace: load older browse activity */ +"Show more" = "Show more"; + /* Username marketplace: contest tallies card title */ "Network vote so far" = "Network vote so far";