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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions DashWallet/Sources/AppleWatch/DWPhoneWCSessionManager.m
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ @interface DWPhoneWCSessionManager () <WCSessionDelegate, SyncingActivityMonitor

@property WCSession *session;
@property id balanceObserver;
// Coalesces bursts of context sends (every balance tick fires one) into a
// single delayed send — building the context is expensive (recent-tx
// snapshot + CoreImage QR render) and `sendApplicationContext` runs on a
// concurrent queue, so undamped bursts pile up overlapping builds.
@property (atomic) BOOL contextSendScheduled;

@end

Expand Down Expand Up @@ -70,7 +75,7 @@ - (instancetype)init {
queue:nil
usingBlock:^(NSNotification *_Nonnull note) {
if ([SyncingActivityMonitor shared].state == SyncingActivityMonitorStateSyncDone)
[self sendApplicationContext];
[self scheduleApplicationContextSend];
}];

[[SyncingActivityMonitor shared] addObserver:self];
Expand All @@ -93,10 +98,26 @@ - (void)syncingActivityMonitorProgressDidChange:(double)progress {

- (void)syncingActivityMonitorStateDidChangeWithPreviousState:(enum SyncingActivityMonitorState)previousState state:(enum SyncingActivityMonitorState)state {
if (state == SyncingActivityMonitorStateSyncDone || state == SyncingActivityMonitorStateSyncFailed) {
[self sendApplicationContext];
[self scheduleApplicationContextSend];
}
}

// Coalesced entry point for runtime context sends: the first request arms a
// 15s timer; requests landing while armed fold into that send (the context
// is built at send time, so it always reflects the latest state). The watch
// face doesn't need tighter freshness than this, and the damping keeps a
// balance-tick storm during sync from queueing overlapping context builds.
- (void)scheduleApplicationContextSend {
if (self.contextSendScheduled)
return;
self.contextSendScheduled = YES;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(15 * NSEC_PER_SEC)),
dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
self.contextSendScheduled = NO;
[self sendApplicationContext];
});
}

- (BOOL)reachable {
return self.session.reachable;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,156 @@ struct UsernameMarketplaceService {
return try await wallet.searchDpnsMarketplace(prefix: prefix, limit: limit)
}

/// The document-history system contract (platform #4348,
/// `document_history_contract::ID_BYTES`). Every `setDocumentPrice`
/// also writes a `priceUpdate` row here, indexed by
/// `[dataContractId, $createdAt]` — the queryable listing trail the
/// DPNS contract itself lacks ($price is not indexable). Browse
/// walks it newest-first, so its cost scales with LISTING EVENTS,
/// not with the size of the namespace.
private static let documentHistoryContractId = "6voHRaoiPcfmMhbqCA9dixH98xcgPQ9UEcuaXjpVu3LD"

/// One DPNS trade event from the history trail — a `priceUpdate`
/// (listing / re-price) or a `purchase`. The event's own price and
/// time are historical FACTS and safe to show as such; only claims
/// about the PRESENT ("for sale now") require the live document.
struct MarketplaceEvent {
/// The history row's own `$id` — the dedupe key that makes the
/// overlap-tolerant pagination cursor safe (see `eventsPage`).
let eventIdBase58: String
let dpnsDocumentIdBase58: String
let priceCredits: UInt64
let createdAtMs: UInt64
/// Purchase events only: buyer (`$ownerId`) and seller, base58.
let buyerIdBase58: String?
let sellerIdBase58: String?
}

/// Newest-first page of DPNS listing / re-price events. `beforeMs`
/// is the pagination cursor (previous page's last `createdAtMs`).
func recentPriceChanges(beforeMs: UInt64?, limit: UInt32 = 25) async throws -> [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.
Expand Down
35 changes: 28 additions & 7 deletions DashWallet/Sources/Models/Coinbase/Coinbase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Data>) -> 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:)))
}
Expand Down Expand Up @@ -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
}

Expand Down
12 changes: 12 additions & 0 deletions DashWallet/Sources/Models/Swap/SwapBuyTransactionMatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
6 changes: 5 additions & 1 deletion DashWallet/Sources/Models/Swap/SwapTrackingService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading
Loading