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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -536,17 +536,43 @@ final class SwiftDashSDKContactsService: ObservableObject {
let descriptor = FetchDescriptor<PersistentDashpayPayment>(
predicate: PersistentDashpayPayment.predicate(
ownerIdentityId: ownerId,
counterpartyIdentityId: contactId),
sortBy: [SortDescriptor(\.createdAt, order: .reverse)])
counterpartyIdentityId: contactId))
let rows = (try? modelContainer.mainContext.fetch(descriptor)) ?? []

// When the payment happened, from the transaction itself.
//
// `PersistentDashpayPayment.createdAt` is row bookkeeping — its own
// model says "not payment dates" — and it only looked like the payment
// date because a live send writes the row as it happens. A payment
// reconstructed after a restore is written today, so every recovered
// row rendered with today's date while the tx-detail screen, which
// reads the transaction, showed the real one.
var blockTimeByTxid: [Data: UInt32] = [:]
if let transactions = try? modelContainer.mainContext.fetch(
FetchDescriptor<PersistentTransaction>()) {
for tx in transactions where tx.blockTimestamp > 0 {
blockTimeByTxid[tx.txid] = tx.blockTimestamp
}
}

return rows.map { row in
let dash = Decimal(row.amountDuffs) / Decimal(100_000_000)
// `PersistentDashpayPayment.txid` is display-order hex; the
// transaction table is keyed by the wire-order bytes.
let wireTxid = Data(hex: row.txid).map { Data($0.reversed()) }
// Unconfirmed (or not-yet-synced) transactions have no block time;
// the row's own timestamp is the best available answer there, and
// for a live send it is the right one.
let date = wireTxid
.flatMap { blockTimeByTxid[$0] }
.map { Date(timeIntervalSince1970: TimeInterval($0)) }
?? row.createdAt
return ContactPayment(
txid: row.txid,
amountDuffs: row.amountDuffs,
direction: row.direction,
memo: row.memo,
date: row.createdAt,
date: date,
// Current-rate conversion (parity with the legacy
// profile, which showed the live fiat equivalent, not a
// historical one). Returns a "Fetching rates…" string
Expand All @@ -555,6 +581,10 @@ final class SwiftDashSDKContactsService: ObservableObject {
? CurrencyExchanger.shared.fiatAmountString(for: dash)
: nil)
}
// Sort on the payment date, not the row's insert order: reconstructed
// rows are all written within the same second, so insert order says
// nothing about which payment came first.
.sorted { $0.date > $1.date }
}

/// Write the owner-private contact metadata (alias / note / hidden)
Expand Down Expand Up @@ -776,24 +806,52 @@ final class SwiftDashSDKContactsService: ObservableObject {
// label is display metadata, not wallet state, so UserDefaults is
// the honest backing.

private func hintKey(for contactId: Data) -> String? {
/// Key prefix shared by every hint of the current (owner, network) pair.
/// Resolved once per batch so a caller reading many contacts does not
/// re-read the identity snapshot per contact.
private static func hintKeyPrefix() -> String? {
guard let ownerId = DWCurrentUserIdentityInfo.shared.identityId,
let network = SwiftDashSDKHost.shared.runningNetwork else {
return nil
}
let ownerHex = ownerId.map { String(format: "%02x", $0) }.joined()
let contactHex = contactId.map { String(format: "%02x", $0) }.joined()
return "dw.contacts.dpnsHint.\(network.rawValue).\(ownerHex).\(contactHex)"
return "dw.contacts.dpnsHint.\(network.rawValue).\(ownerHex)."
}

private static func hintKey(for contactId: Data) -> String? {
guard let prefix = hintKeyPrefix() else { return nil }
return prefix + contactId.map { String(format: "%02x", $0) }.joined()
}

/// DPNS labels for `contactIds`, keyed by contact identity id.
///
/// `static` on purpose. `DashPayPaymentTxLookup` needs the same labels the
/// contacts snapshot uses, but it must not reach `SwiftDashSDKContactsService`
/// **`.shared`** to get them: `init()` ends in `refresh()`, which calls
/// `DashPayPaymentTxLookup.shared.refresh()`, so touching `.shared` from
/// there re-enters the singleton's own `swift_once` and traps
/// (`EXC_BREAKPOINT` reported on the `static let shared` line). Nothing
/// here reads instance state, so there is no reason to route through it.
static func usernameHints(for contactIds: some Sequence<Data>) -> [Data: String] {
guard let prefix = hintKeyPrefix() else { return [:] }
var out: [Data: String] = [:]
for contactId in contactIds where out[contactId] == nil {
let key = prefix + contactId.map { String(format: "%02x", $0) }.joined()
if let value = UserDefaults.standard.string(forKey: key), !value.isEmpty {
out[contactId] = value
}
}
return out
}

private func usernameHint(for contactId: Data) -> String? {
guard let key = hintKey(for: contactId) else { return nil }
guard let key = Self.hintKey(for: contactId) else { return nil }
let value = UserDefaults.standard.string(forKey: key)
return (value?.isEmpty == false) ? value : nil
}

private func setUsernameHint(_ label: String, for contactId: Data) {
guard let key = hintKey(for: contactId) else { return }
guard let key = Self.hintKey(for: contactId) else { return }
UserDefaults.standard.set(label, forKey: key)
}
}
Expand Down Expand Up @@ -842,10 +900,21 @@ final class DashPayPaymentTxLookup {
let counterpartyAlias: String?
/// Counterparty's avatar URL, when their profile carries one.
let counterpartyAvatarURL: String?
/// Counterparty's DPNS label, when one has been resolved. Most
/// contacts have no `dashpay.profile.displayName`, so without this
/// step the row had no name at all and rendered as "?" — while the
/// contacts list, which does consult DPNS, showed the username.
let counterpartyUsername: String?

/// Row-title name: alias first (owner's own label for the contact),
/// then the profile display name. Matches `ContactItem.displayTitle`.
var titleName: String? { counterpartyAlias ?? counterpartyName }
/// then the profile display name, then the DPNS label — the first
/// three steps of `ContactItem.displayTitle`. It deliberately stops
/// there: `displayTitle`'s truncated-identity last resort is right for
/// a contact row that must render something, but a transaction row
/// falls back to its own generic title, which beats "Sent to 89fd6ddb…".
var titleName: String? {
counterpartyAlias ?? counterpartyName ?? counterpartyUsername
}
}

private let lock = NSLock()
Expand Down Expand Up @@ -890,6 +959,11 @@ final class DashPayPaymentTxLookup {
aliasByContactId[request.contactIdentityId] = alias
}
}
// DPNS labels, resolved in one batch — see `usernameHints`, which
// is static precisely so this cannot touch the contacts service
// singleton while that singleton is still initializing.
let usernameByContactId = SwiftDashSDKContactsService.usernameHints(
for: rows.lazy.filter { $0.amountDuffs > 0 }.map(\.counterpartyIdentityId))
var map: [String: PaymentInfo] = [:]
for row in rows where row.amountDuffs > 0 {
let profile = profileByContactId[row.counterpartyIdentityId]
Expand All @@ -899,7 +973,9 @@ final class DashPayPaymentTxLookup {
counterpartyIdentityId: row.counterpartyIdentityId,
counterpartyName: profile?.name?.isEmpty == false ? profile?.name : nil,
counterpartyAlias: aliasByContactId[row.counterpartyIdentityId],
counterpartyAvatarURL: profile?.avatarURL?.isEmpty == false ? profile?.avatarURL : nil)
counterpartyAvatarURL: profile?.avatarURL?.isEmpty == false ? profile?.avatarURL : nil,
counterpartyUsername: usernameByContactId[row.counterpartyIdentityId]?
.withoutDashSuffix)
}
store(map)
} catch {
Expand Down
120 changes: 120 additions & 0 deletions DashWallet/Sources/Infrastructure/SwiftDashSDK/SwiftDashSDKHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,12 @@ final class SwiftDashSDKHost {
private(set) var runningNetwork: Network?
private let modelContainerCache = ProcessNetworkValueCache<ModelContainer>()

/// Watches for contact-crypto work that gets deferred *after* the
/// load-time unlock. See `unlockDashPayContactCrypto`. Replaced (and the
/// previous one cancelled) whenever a wallet is loaded, so only the active
/// wallet is watched.
private var contactCryptoDrainWatch: Task<Void, Never>?

// MARK: - Process-wide SDK init guard

private static var sdkInitialized = false
Expand Down Expand Up @@ -657,12 +663,126 @@ final class SwiftDashSDKHost {
let restored = try manager.loadFromPersistor()
if let resolved = resolveActiveWallet(in: manager, network: network) {
Self.logger.info("🪺 HOST :: reusing persisted wallet; restored=\(restored.count, privacy: .public)")
// Off the load path. `PlatformWalletManager` is `@MainActor`, so
// the unlock's Keychain read and its two synchronous FFI calls run
// on the main thread whenever they run; scheduling them as their
// own main-actor turn at least keeps them out of wallet load,
// which is on the launch critical path.
Task { [weak self] in
self?.unlockDashPayContactCrypto(manager: manager, wallet: resolved)
}
return resolved
}

throw HostError.walletNotFound(network)
}

/// Complete the contact crypto that `loadFromPersistor` had to defer.
///
/// A persisted restore rehydrates the wallet external-signable — per-account
/// xpubs, no key material. The DashPay contact sweep needs a signer to ECDH
/// each contact's encrypted xpub into a `DashpayExternalAccount`, so with no
/// signer present it enqueues the build instead ("Deferred DashPay account
/// build") and re-enqueues it every sweep. Until something drains that queue
/// the wallet has no external accounts, which means no derived contact
/// addresses: sent-payment history cannot be reconstructed after a restore,
/// and the contact card stays on "No payments with this contact yet".
///
/// `send_payment` drains the queue with its own signer, so the gap only
/// showed on wallets that had not sent to the contact since restoring.
/// This is the signer-backed drain the SDK documents on
/// `unlockWalletFromKeychain`; the drain itself re-fetches over the network
/// and runs detached, so the call returns immediately.
///
/// Best-effort by design — a failure here must not fail wallet load. It
/// returns `false` for a genuine watch-only wallet (no stored mnemonic) and
/// throws only when the resolved seed does not bind to this wallet, which is
/// worth a log line but not a launch failure.
///
/// Logged through `DWLogger` rather than `os_log` on purpose: this line has
/// to survive into the `app-logs/` group of a diagnostic export, and the
/// `os-log.txt` capture is not on this branch. It reports the three states
/// that tell apart the ways the drain can fail to happen — no stored
/// mnemonic for this wallet id (unlock no-ops), an empty queue (nothing was
/// deferred), and a seed that does not bind (throws).
private func unlockDashPayContactCrypto(
manager: PlatformWalletManager,
wallet: ManagedPlatformWallet
) {
let walletId = wallet.walletId
let idTag = walletId.prefix(4).map { String(format: "%02x", $0) }.joined()
// Existence-only probe on the active wallet's own id — the same check
// `unlockWalletFromKeychain` makes internally, surfaced so a `false`
// return is distinguishable from "never called". No plaintext is read.
let hasMnemonic = WalletStorage().hasMnemonic(for: walletId)
let pending = (try? manager.pendingAccountBuildCount(for: walletId)).map(String.init) ?? "n/a"
do {
// Timed because it is main-thread work: the seed-binding verify is
// marker-cached, but a cache miss re-derives the BIP44 account-0
// xpub through the Keychain resolver. If a UI stall lines up with
// this line, that is the cost.
let started = CFAbsoluteTimeGetCurrent()
let unlocked = try manager.unlockWalletFromKeychain(wallet)
let ms = Int((CFAbsoluteTimeGetCurrent() - started) * 1000)
DWLogger.log(
"DashPay unlock: wallet=\(idTag) hasMnemonic=\(hasMnemonic) pendingAccountBuilds=\(pending) unlocked=\(unlocked) tookMs=\(ms)")
} catch {
DWLogger.log(
"DashPay unlock FAILED: wallet=\(idTag) hasMnemonic=\(hasMnemonic) pendingAccountBuilds=\(pending) error=\(String(describing: error))")
}

// A single unlock at load time is not enough: it schedules the drain
// only when the queue is already non-empty, and at this point it is
// empty — the contact sweep that defers the account builds has not run
// yet. Measured on a restored wallet: 0 pending at unlock, 4 pending
// 45s later, and nothing to drain them. So keep watching and unlock
// again once work appears. Re-unlocking is cheap — the seed-binding
// verify is marker-cached after the first success.
contactCryptoDrainWatch?.cancel()
contactCryptoDrainWatch = Task { [weak self] in
// Bounded: a queue that survives its drains is a failure to report,
// not something to retry forever.
var attemptsLeft = 5
var drained = false
for await statuses in manager.$dashPayUnlockStatus.values {
if Task.isCancelled || self == nil { return }
guard let status = statuses[walletId] else { continue }
if status.pendingAccountBuilds == 0, !status.draining {
guard drained else { continue }
// The contact accounts exist now. Sweep immediately rather
// than waiting out the DashPay sync interval — that wait is
// most of why a restored wallet showed an empty contact
// card on first open and its history only on a later one.
DWLogger.log("DashPay unlock: wallet=\(idTag) drain complete; syncing now")
await SwiftDashSDKContactsService.shared.syncNow()
return
}
guard status.pendingAccountBuilds > 0, !status.draining else { continue }
guard attemptsLeft > 0 else {
DWLogger.log(
"DashPay unlock: wallet=\(idTag) giving up with \(status.pendingAccountBuilds) pending account build(s)")
return
}
attemptsLeft -= 1
do {
let started = CFAbsoluteTimeGetCurrent()
let unlocked = try manager.unlockWalletFromKeychain(wallet)
let ms = Int((CFAbsoluteTimeGetCurrent() - started) * 1000)
DWLogger.log(
"DashPay unlock: wallet=\(idTag) draining \(status.pendingAccountBuilds) deferred build(s); unlocked=\(unlocked) tookMs=\(ms)")
drained = true
} catch {
DWLogger.log(
"DashPay unlock: wallet=\(idTag) re-unlock failed: \(String(describing: error))")
return
}
// Let `draining` publish before the next element is considered,
// so one drain isn't counted as several attempts.
try? await Task.sleep(for: .seconds(5))
}
}
}

/// `WalletEnvironment.NetworkKind` for the SDK `Network` — the app-side
/// key the active-wallet registry is scoped by. Only `.mainnet` /
/// `.testnet` reach the registry; `.devnet`/`.regtest` don't run a
Expand Down
Loading