From b9c7399035ad73887367014118c3d60613808a7c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 12 Aug 2026 14:40:14 +0700 Subject: [PATCH] fix(swift-sdk): isLocal = mine-or-tracked; promote wallet identities, fix observed-entry mislinking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner-decided semantics for PersistentIdentity.isLocal: true for every identity that is YOURS or deliberately tracked on this device — wallet-derived identities always ('things from the wallet should always be local'), manual adds (LoadIdentityView by id/name) always — and false only for incidental rows (observed foreign identities materialized by sync). Promote-only: no sync path ever writes false over true. The field bug: the persister wrote a constant isLocal: false for every row it created and nothing promoted, so the wallet's own identity (correct wallet relationship, identityIndex 0) showed as not-local on a mainnet device, hiding the identity-key refresh affordances in dashwallet-ios. LoadIdentityView additionally clobbered its own manual adds with false. - persistIdentities promotes isLocal when it attaches the wallet relationship; observed rows stay false; existing rows are never demoted (a manual mark survives sync flowing over the row). - Wallet-relationship hygiene, fixed alongside because the same audit exposed it: the scope-wallet fallback is gated on identity_index != nil, so out-of-wallet (observed) entries no longer get mislinked to whatever wallet's changeset carried them; a fabricated scope link is cleared when the identity re-emits observed; a link matching a declared-but-unresolvable owner survives, while one contradicting the declaration is cleared; another wallet's valid relationship always survives a foreign manager's observed emission. - loadWalletList() runs a one-shot promote-only heal (wallet-linked && !isLocal → true) for stores written by the constant-false era. - LoadIdentityView marks its rows isLocal: true (manual add) instead of erasing the very provenance the flag records. - Example app: retire the dead 'Local Only / On Network' badge reading; the rare incidental rows show 'Observed'; the !isLocal network-feature gates (DPNS/tokens/profile/refresh) are removed — under the real semantics they would have hidden those features for every wallet identity; wallet-signing actions (register name, marketplace) gate on hasLoadedWallet. isLocal deliberately makes NO claim about signing capability; consumers that need capability compute it live, and wallet-owned filtering has walletOwnedIdentitiesPredicate. This supersedes the capability-oriented hardening series, archived unmerged on archive/islocal-review-hardening. Covered by IdentityIsLocalPersistenceTests: promotion (direct + index-fallback), observed rows stay incidental and unlinked, sync never demotes manual adds, declared-owner keep/clear, cross-wallet preservation, and the startup heal. 335 tests pass on simulator; the two IdentityResolverSignIntegrationTests keychain-entitlement failures pre-exist on the baseline. Co-Authored-By: Claude Fable 5 --- .../Models/PersistentIdentity.swift | 32 +- .../PlatformWalletPersistenceHandler.swift | 122 ++++++-- .../Views/DashPay/DashPayTabView.swift | 12 +- .../Views/IdentitiesView.swift | 57 ++-- .../Views/IdentityDetailView.swift | 80 ++--- .../Views/LoadIdentityView.swift | 8 +- .../IdentityIsLocalPersistenceTests.swift | 294 ++++++++++++++++++ 7 files changed, 480 insertions(+), 125 deletions(-) create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityIsLocalPersistenceTests.swift diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentity.swift index 5611e654b2c..72e637a815c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentity.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentity.swift @@ -13,6 +13,27 @@ public final class PersistentIdentity { @Attribute(.unique) public var identityId: Data public var balance: Int64 public var revision: Int64 + /// `true` iff this identity is YOURS or deliberately tracked on + /// this device, two ways in: + /// - wallet-derived: identities of a wallet on this device are + /// ALWAYS local — the persister promotes the flag when it + /// attaches the `wallet` relationship, and the startup heal + /// repairs rows persisted before that rule existed; + /// - manually added: the user loaded/watched the identity via a + /// UI flow (LoadIdentityView by id/name), which marks its own + /// row (the initializer default `true` matches — a directly + /// constructed row is a manual add). + /// + /// `false` only for incidental rows — observed foreign + /// identities materialized by sync that nobody asked to track. + /// The flag is PROMOTE-ONLY: no sync path ever writes `false` + /// over a `true` (a manual mark must survive Platform data + /// flowing over the row, and losing a wallet link doesn't + /// un-track an identity). + /// + /// It makes no claim about signing capability — compute that + /// live where needed; wallet-owned filtering has + /// `walletOwnedIdentitiesPredicate`. public var isLocal: Bool public var alias: String? /// User's chosen primary display label (the one rendered on @@ -292,12 +313,11 @@ extension PersistentIdentity { /// `wallet` relationship. Use this for views that should only /// surface identities the user can act as / sign for. /// - /// Distinct from the `isLocal` flag — that drives the - /// "Local Only" / "On Network" UI badge (Platform-confirmed vs - /// pending broadcast). Wallet ownership is orthogonal: an - /// identity can be wallet-owned and `isLocal` (just registered, - /// not yet confirmed), wallet-owned and on-network (confirmed), - /// or out-of-wallet (DashPay contact / payment recipient). + /// Distinct from the `isLocal` flag: wallet-owned identities are + /// a subset of local ones (`wallet != nil` ⟹ `isLocal`, and + /// manual adds are local without any wallet). Use this predicate + /// when the operation needs the wallet itself (signing, DIP-9 + /// reload); use `isLocal` for "show as mine/tracked" UI. public static var walletOwnedIdentitiesPredicate: Predicate { #Predicate { identity in identity.wallet != nil diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 06b5289ed8e..b8b1bcde6cb 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -1711,15 +1711,19 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // `.testnet` so we never block the write path on a // missing network column (the CreateIdentity flow // restamps the network on return anyway). - let resolvedWalletId = entry.walletId ?? walletId - let network = walletNetwork(walletId: resolvedWalletId) ?? .testnet - // `isLocal` is the "Local Only" badge in the UI — - // identities the user created locally but Platform - // hasn't confirmed yet. The persister fires *after* - // Platform has confirmed, so any row created here - // is by definition on-network. Wallet ownership - // travels on `row.wallet` (the relationship set - // below), not on this flag. + let networkWalletId = entry.walletId ?? walletId + let network = walletNetwork(walletId: networkWalletId) ?? .testnet + // `isLocal` = "this identity is yours or tracked + // here": wallet-derived identities are ALWAYS local + // (promoted below once the wallet linkage attaches) + // and manual adds (LoadIdentityView et al.) mark + // their own rows local. Only incidental rows — + // observed foreign identities materialized by sync — + // stay `false`. Seed `false` at creation; the + // wallet-attach below promotes wallet-owned rows, + // and NOTHING ever demotes (sync must not erase a + // user's manual mark, and losing a wallet link + // doesn't un-track an identity). row = PersistentIdentity( identityId: entry.identityId, balance: Int64(bitPattern: entry.balance), @@ -1802,28 +1806,49 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } // Attach the identity to its owning `PersistentWallet` - // via the relationship. This is the sole wallet-side - // association on the row — there is no denormalized - // scalar — so downstream `@Query` views traverse - // `identity.wallet?.walletId` when they need the raw - // id. `deleteRule: .nullify` on the inverse nulls this - // out cleanly if the wallet row is ever removed. + // via the relationship — the sole wallet-side + // association on the row (`deleteRule: .nullify` on the + // inverse nulls it if the wallet row is removed). // - // Wallet id resolution: prefer the per-entry - // `walletId` when Rust sets it (covers corner cases - // where a changeset carries identities anchored to a - // different wallet — e.g. a BLAST pass that surfaces - // foreign identities the local wallet observes). Fall - // back to the scope `walletId` that parameterised this - // callback, which is always the wallet whose - // changeset we're applying. The fallback matters for - // the "create new identity" flow: Rust emits the - // identity entry with `wallet_id_is_some == false` - // (the identity wasn't wallet-linked in its own Rust - // struct at emit time), and without the fallback we'd - // orphan the just-registered row. - let resolvedWalletId = entry.walletId ?? walletId - row.wallet = fetchWalletForLink(walletId: resolvedWalletId) + // Owner resolution: prefer the per-entry `walletId`; + // an entry with no `walletId` but a real + // `identityIndex` is wallet-derived and falls back to + // the scope wallet (the "create new identity" corner + // case). An entry with NEITHER is an out-of-wallet + // (observed) identity — `add_out_of_wallet_identity` + // emits that shape — and must NOT inherit the scope + // wallet: the old unconditional fallback mislinked + // observed identities to whatever wallet's changeset + // carried them. + let ownerWalletId: Data? = + entry.walletId ?? (entry.identityIndex != nil ? walletId : nil) + if let ownerWallet = fetchWalletForLink(walletId: ownerWalletId) { + row.wallet = ownerWallet + // Things from the wallet are always local — promote. + // One-way: no path ever writes `false` over a `true`. + row.isLocal = true + } else if let declaredOwnerId = ownerWalletId { + // Declared owner didn't resolve (e.g. its wallet row + // is absent on this handler's network scope). Keep + // the existing link only when it already points at + // that declared owner; a link to any OTHER wallet + // contradicts the entry's declared ownership and is + // cleared. + if row.wallet?.walletId != declaredOwnerId { + row.wallet = nil + } + } else if row.wallet?.walletId == walletId { + // A genuinely out-of-wallet entry unlinks ONLY a + // relationship to this changeset's scope wallet — + // the one the old fallback could have fabricated. + // "Out-of-wallet" is relative to the emitting Rust + // manager: wallet A resolving wallet B's identity + // via `load_identity_by_dpns_name` emits the + // nil/nil shape from A's manager, and the row is + // globally keyed by identityId, so wallet B's valid + // relationship must survive. + row.wallet = nil + } } for identityId in removed { @@ -4652,9 +4677,46 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// wallet_id, accounts)`; accounts come directly from the spec /// array, wallet id from the top-level struct. /// + /// One-shot upgrade heal: promote `isLocal` on wallet-linked rows + /// still carrying `false` — the persister used to write a + /// constant `false`, so a wallet's own identities (which are + /// always local) were mis-marked on stores from that era. + /// Promote-only and idempotent; a `true` on an unlinked row + /// (manual add) is never touched. Runs here because load is the + /// one guaranteed per-launch pass over the store, outside any + /// changeset round. + private func healIdentityIsLocalFlags() { + guard !inChangeset else { return } + guard let rows = try? backgroundContext.fetch( + FetchDescriptor() + ) else { return } + var healed = 0 + for row in rows where row.wallet != nil && !row.isLocal { + row.isLocal = true + healed += 1 + } + guard healed > 0 else { return } + do { + try backgroundContext.save() + NSLog( + "[persistor-load:swift] healed isLocal on %d identity row(s)", + healed + ) + } catch { + // Non-fatal: the next launch retries. Roll back so the + // failed heal can't bleed into the restore fetches below. + backgroundContext.rollback() + NSLog( + "[persistor-load:swift] isLocal heal save failed: %@", + String(describing: error) + ) + } + } + /// Returns `(nil, 0)` if nothing is restorable. func loadWalletList() -> (entries: UnsafePointer?, count: Int, errored: Bool) { onQueue { + healIdentityIsLocalFlags() // Scope the fetch to the handler's bound network so a // per-network manager only sees its own wallets. If // `network` is `nil` (legacy callers that haven't threaded diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift index cc730e5ce76..1eb043a4c22 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/DashPayTabView.swift @@ -102,14 +102,14 @@ struct DashPayTabView: View { ) } - /// Identities the DashPay tab can act as: on-network (not - /// local-only) and backed by a wallet that's currently loaded in - /// the manager — every DashPay FFI call resolves through that - /// wallet handle. + /// Identities the DashPay tab can act as: backed by a wallet + /// that's currently loaded in the manager — every DashPay FFI + /// call resolves through that wallet handle. (NOT gated on + /// `isLocal`: wallet-derived identities are always local, so + /// that flag doesn't discriminate here; the wallet linkage does.) private var eligibleIdentities: [PersistentIdentity] { identities.filter { identity in - guard !identity.isLocal, - let walletId = identity.wallet?.walletId else { return false } + guard let walletId = identity.wallet?.walletId else { return false } return walletManager.wallet(for: walletId) != nil } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentitiesView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentitiesView.swift index b1463daab61..461e2f70446 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentitiesView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentitiesView.swift @@ -99,38 +99,35 @@ struct IdentityRow: View { } } - if identity.isLocal { - HStack { - Image(systemName: "location") + // `isLocal` = mine-or-tracked (wallet-derived rows + // are always local; manual adds too). Flag only the + // rare incidental rows; the balance refresh is a + // plain Platform fetch, valid for every row. + HStack { + if !identity.isLocal { + Image(systemName: "eye") .font(.caption2) - Text("Local Only") + Text("Observed") .font(.caption2) + .foregroundColor(.secondary) } - .foregroundColor(.orange) - } else { - HStack { - Image(systemName: "checkmark.circle.fill") - .font(.caption2) - Text("On Network") - .font(.caption2) - Spacer() + Spacer() - Button(action: { - isRefreshing = true - Task { - await refreshBalance() - isRefreshing = false - } - }) { - Image(systemName: "arrow.clockwise") - .font(.caption) - .foregroundColor(.blue) - .rotationEffect(.degrees(isRefreshing ? 360 : 0)) - .animation(isRefreshing ? .linear(duration: 1).repeatForever(autoreverses: false) : .default, value: isRefreshing) + Button(action: { + isRefreshing = true + Task { + await refreshBalance() + isRefreshing = false } - .buttonStyle(BorderlessButtonStyle()) + }) { + Image(systemName: "arrow.clockwise") + .font(.caption) + .foregroundColor(.blue) + .rotationEffect(.degrees(isRefreshing ? 360 : 0)) + .animation(isRefreshing ? .linear(duration: 1).repeatForever(autoreverses: false) : .default, value: isRefreshing) } + .buttonStyle(BorderlessButtonStyle()) } } .padding(.vertical, 4) @@ -187,11 +184,11 @@ struct IdentityRow: View { try? modelContext.save() } catch { - if !identity.isLocal { - appState.showError( - message: "Failed to refresh balance: \(error.localizedDescription)" - ) - } + // Every persisted row exists on Platform, so a failed + // refresh is worth surfacing for all of them. + appState.showError( + message: "Failed to refresh balance: \(error.localizedDescription)" + ) } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift index 50acbee3e91..edc3ed10dbf 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/IdentityDetailView.swift @@ -158,13 +158,11 @@ struct IdentityDetailView: View { .fontWeight(.medium) } - // Top-up entry point. Hidden for purely-local rows - // (no on-chain identity to credit yet) and for - // identities whose owning wallet isn't loaded into - // the manager — both paths would just surface a - // confusing error from the FFI layer. - if !identity.isLocal, - let walletId = identity.wallet?.walletId, + // Top-up entry point. Hidden for identities whose + // owning wallet isn't loaded into the manager — that + // path would just surface a confusing error from the + // FFI layer. + if let walletId = identity.wallet?.walletId, walletManager.wallet(for: walletId) != nil { Button { showingTopUp = true @@ -223,21 +221,13 @@ struct IdentityDetailView: View { identity.identityTypeEnum == .masternode ? .purple : .orange) } - if identity.isLocal { - HStack { - Label("Status", systemImage: "location") - Spacer() - Text("Local Only") - .foregroundColor(.secondary) - } - } } // DPNS Names Section - if !dpnsNames.isEmpty || !contestedDpnsNames.isEmpty || !identity.isLocal - || hasLoadedWallet(for: identity) { - Section("DPNS Names") { - if !identity.isLocal && hasLoadedWallet(for: identity) { + // Every persisted identity exists on Platform, so the + // section always renders. + Section("DPNS Names") { + if hasLoadedWallet(for: identity) { NavigationLink(destination: DpnsMarketplaceView(identity: identity)) { Label("Username Marketplace", systemImage: "storefront") } @@ -293,8 +283,9 @@ struct IdentityDetailView: View { } } - // Register name button - if !identity.isLocal { + // Register name button — registration signs + // through the identity's loaded wallet. + if hasLoadedWallet(for: identity) { Button(action: { showingRegisterName = true }) { HStack { Image(systemName: "plus.circle") @@ -303,7 +294,6 @@ struct IdentityDetailView: View { .foregroundColor(.blue) } } - } } // Tokens Section @@ -316,8 +306,7 @@ struct IdentityDetailView: View { // round-trip. Transient @State only — persistence to // PersistentTokenBalance lives in the platform-wallet // sync path, not here. - if !identity.isLocal { - Section { + Section { if isLoadingTokens && tokenBalances.isEmpty { HStack(spacing: 10) { ProgressView() @@ -365,7 +354,6 @@ struct IdentityDetailView: View { .disabled(isLoadingTokens) } } - } // DashPay Profile Section // @@ -374,13 +362,7 @@ struct IdentityDetailView: View { // via `syncDashPayProfiles()` so the cache reflects the // latest on-chain state without blocking the first paint. Section("DashPay Profile") { - if !identity.isLocal { - dashPayProfileCard(identity: identity) - } else { - Text("Available once the identity is on the network.") - .font(.caption) - .foregroundColor(.secondary) - } + dashPayProfileCard(identity: identity) } // Keys Section @@ -408,20 +390,18 @@ struct IdentityDetailView: View { } // Actions Section - if !identity.isLocal { - Section { - Button(action: refreshIdentityData) { - HStack { - Image(systemName: "arrow.clockwise") - Text("Refresh Identity Data") - Spacer() - if isRefreshing { - ProgressView() - } + Section { + Button(action: refreshIdentityData) { + HStack { + Image(systemName: "arrow.clockwise") + Text("Refresh Identity Data") + Spacer() + if isRefreshing { + ProgressView() } } - .disabled(isRefreshing) } + .disabled(isRefreshing) } } .navigationTitle("Identity Details") @@ -486,14 +466,14 @@ struct IdentityDetailView: View { print("🔵 IdentityDetailView onAppear - dpnsName: \(identity.dpnsName ?? "nil"), isLocal: \(identity.isLocal)") // Load DPNS names from network if we don't have any cached or if they're empty - if (dpnsNames.isEmpty && contestedDpnsNames.isEmpty) && !identity.isLocal { + if dpnsNames.isEmpty && contestedDpnsNames.isEmpty { print("🔵 No cached DPNS names, loading from network...") loadDPNSNames() - } else if !dpnsNames.isEmpty || !contestedDpnsNames.isEmpty { + } else { print("🔵 Using cached DPNS names: \(dpnsNames.count) regular, \(contestedDpnsNames.count) contested") } - if !identity.isLocal { + do { // Read whatever's currently cached synchronously so the // card renders immediately, then kick off a background // sync to freshen it. The sync uses the merged @@ -571,8 +551,7 @@ struct IdentityDetailView: View { } private func loadDPNSNames() { - guard let identity = identity, - !identity.isLocal else { return } + guard identity != nil else { return } Task { await loadDPNSNamesFromNetwork() @@ -580,8 +559,7 @@ struct IdentityDetailView: View { } private func loadDPNSNamesFromNetwork() async { - guard let identity = identity, - !identity.isLocal else { return } + guard let identity = identity else { return } print("🔵 loadDPNSNamesFromNetwork called for identity \(identity.identityIdBase58)") @@ -903,7 +881,7 @@ struct IdentityDetailView: View { /// the UI can show a spinner without blocking. @MainActor private func reloadTokenBalances() { - guard let identity = identity, !identity.isLocal, + guard let identity = identity, let sdk = appState.sdk else { return } // In-flight guard: `onAppear` now reloads unconditionally and this diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/LoadIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/LoadIdentityView.swift index fdfaab40f7c..432cd0cd4d7 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/LoadIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/LoadIdentityView.swift @@ -421,7 +421,11 @@ struct LoadIdentityView: View { if let existing = existing { existing.balance = Int64(bitPattern: fetchedBalance) existing.alias = trimmedAlias - existing.isLocal = false + // The user just added/reloaded this identity + // by hand — mark the provenance. (This flow + // previously wrote `false`, which erased the + // very thing the flag exists to record.) + existing.isLocal = true existing.identityType = identityType.rawValue existing.network = network existing.lastUpdated = Date() @@ -434,7 +438,7 @@ struct LoadIdentityView: View { identityId: validIdData, balance: Int64(bitPattern: fetchedBalance), revision: 0, - isLocal: false, + isLocal: true, alias: trimmedAlias, dpnsName: nil, mainDpnsName: nil, diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityIsLocalPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityIsLocalPersistenceTests.swift new file mode 100644 index 00000000000..dc891ae1469 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityIsLocalPersistenceTests.swift @@ -0,0 +1,294 @@ +import XCTest +import SwiftData +import DashSDKFFI +@testable import SwiftDashSDK + +// MARK: - `isLocal` = mine-or-tracked, and wallet-linkage hygiene +// +// Owner-decided semantics: `isLocal` is `true` for every identity +// that is YOURS or deliberately tracked on this device — wallet-derived +// identities always, manual adds (LoadIdentityView) always — and +// `false` only for incidental rows (observed foreign identities +// materialized by sync). Promote-only: no sync path ever writes +// `false` over a `true`. +// +// Historically the persister wrote a constant `false` and nothing +// promoted, so the wallet's own identity (correct `wallet` +// relationship, index 0) showed as not-local on a mainnet device, +// hiding the identity-key refresh affordances in dashwallet-ios. +// These tests pin the promotion, the promote-only discipline, the +// startup heal, and the wallet-relationship hygiene fixed alongside +// (observed entries must not inherit the changeset's scope wallet). + +final class IdentityIsLocalPersistenceTests: XCTestCase { + + private var container: ModelContainer! + private var handler: PlatformWalletPersistenceHandler! + + private let walletId = Data(repeating: 0xAA, count: 32) + private let ownIdentityId = Data(repeating: 0x01, count: 32) + private let observedIdentityId = Data(repeating: 0x02, count: 32) + + override func setUpWithError() throws { + try super.setUpWithError() + container = try DashModelContainer.createInMemory() + handler = PlatformWalletPersistenceHandler( + modelContainer: container, + network: .testnet + ) + } + + override func tearDown() { + handler = nil + container = nil + super.tearDown() + } + + // MARK: Fixtures + + private func insertWalletRow() throws { + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + } + + private func makeEntry( + identityId: Data, + identityIndex: UInt32?, + walletId: Data? + ) -> PlatformWalletPersistenceHandler.IdentityEntrySnapshot { + .init( + identityId: identityId, + balance: 100, + revision: 1, + identityIndex: identityIndex, + label: nil, + status: 0, + walletId: walletId, + dpnsNames: [], + dashpayProfile: nil, + contactProfiles: [] + ) + } + + /// Apply one identity persister round the way the FFI does — + /// bracketed by `beginChangeset` / `endChangeset(success: true)`. + private func applyIdentities( + _ upserts: [PlatformWalletPersistenceHandler.IdentityEntrySnapshot] + ) { + handler.beginChangeset(walletId: walletId) + handler.persistIdentities( + walletId: walletId, + upserts: upserts, + removed: [] + ) + handler.endChangeset(walletId: walletId, success: true) + } + + private func fetchIdentity(_ identityId: Data) throws -> PersistentIdentity? { + let context = ModelContext(container) + return try context.fetch( + FetchDescriptor( + predicate: #Predicate { $0.identityId == identityId } + ) + ).first + } + + // MARK: Promotion + + /// A wallet-owned entry links the wallet relationship AND + /// promotes `isLocal` — things from the wallet are always local + /// (the mainnet field bug was exactly this row showing `false`). + func testWalletOwnedEntryIsLocal() throws { + try insertWalletRow() + applyIdentities([ + makeEntry(identityId: ownIdentityId, identityIndex: 0, walletId: walletId) + ]) + + let row = try XCTUnwrap(try fetchIdentity(ownIdentityId)) + XCTAssertEqual(row.wallet?.walletId, walletId) + XCTAssertTrue(row.isLocal, "wallet-derived identities are always local") + } + + /// A wallet-derived entry without its own `wallet_id` but WITH an + /// identity index (create-flow corner case) falls back to the + /// changeset's scope wallet and promotes. + func testIndexOnlyEntryFallsBackToScopeWalletAndPromotes() throws { + try insertWalletRow() + applyIdentities([ + makeEntry(identityId: ownIdentityId, identityIndex: 0, walletId: nil) + ]) + + let row = try XCTUnwrap(try fetchIdentity(ownIdentityId)) + XCTAssertEqual(row.wallet?.walletId, walletId) + XCTAssertTrue(row.isLocal) + } + + /// An out-of-wallet (observed) entry — `identity_index == nil` + /// and `wallet_id == nil`, the `add_out_of_wallet_identity` + /// shape — is an incidental row: not linked to the scope wallet + /// (the old unconditional fallback mislinked these) and not + /// local. + func testObservedEntryStaysIncidental() throws { + try insertWalletRow() + applyIdentities([ + makeEntry(identityId: observedIdentityId, identityIndex: nil, walletId: nil) + ]) + + let row = try XCTUnwrap(try fetchIdentity(observedIdentityId)) + XCTAssertNil( + row.wallet, + "observed identities must not inherit the changeset's scope wallet" + ) + XCTAssertFalse(row.isLocal) + } + + // MARK: Promote-only discipline + + /// A manually-added row (`isLocal == true`, no wallet) keeps its + /// mark when sync later flows over it as an observed entry — and + /// a scope-wallet mislink is cleared WITHOUT demoting. + func testSyncNeverDemotesManualAdds() throws { + try insertWalletRow() + let context = ModelContext(container) + let wallet = try XCTUnwrap( + try context.fetch(FetchDescriptor()).first + ) + // Manual add, later mislinked by the old fallback. + let manual = PersistentIdentity( + identityId: observedIdentityId, + isLocal: true, + network: .testnet + ) + manual.wallet = wallet + context.insert(manual) + try context.save() + + applyIdentities([ + makeEntry(identityId: observedIdentityId, identityIndex: nil, walletId: nil) + ]) + + let row = try XCTUnwrap(try fetchIdentity(observedIdentityId)) + XCTAssertNil(row.wallet, "the fabricated scope-wallet link is cleared") + XCTAssertTrue(row.isLocal, "sync must never erase a manual mark") + } + + // MARK: Wallet-relationship hygiene + + /// An entry declaring an owner whose wallet row misses the + /// network-scoped fetch keeps a MATCHING existing link… + func testDeclaredOwnerFetchMissKeepsMatchingLink() throws { + let otherWalletId = Data(repeating: 0xCC, count: 32) + let context = ModelContext(container) + let mainnetWallet = PersistentWallet(walletId: otherWalletId, network: .mainnet) + context.insert(mainnetWallet) + let row = PersistentIdentity( + identityId: ownIdentityId, + isLocal: true, + network: .testnet + ) + row.wallet = mainnetWallet + context.insert(row) + try context.save() + + applyIdentities([ + makeEntry(identityId: ownIdentityId, identityIndex: 0, walletId: otherWalletId) + ]) + + let fetched = try XCTUnwrap(try fetchIdentity(ownIdentityId)) + XCTAssertEqual(fetched.wallet?.walletId, otherWalletId) + } + + /// …while a link CONTRADICTING the declared owner is cleared + /// (the declaration is Rust's current truth) — without demoting. + func testDeclaredOwnerMismatchClearsContradictingLink() throws { + try insertWalletRow() + let context = ModelContext(container) + let scopeWallet = try XCTUnwrap( + try context.fetch(FetchDescriptor()).first + ) + let row = PersistentIdentity( + identityId: ownIdentityId, + isLocal: true, + network: .testnet + ) + row.wallet = scopeWallet + context.insert(row) + try context.save() + + let unresolvedOwnerId = Data(repeating: 0xDD, count: 32) + applyIdentities([ + makeEntry(identityId: ownIdentityId, identityIndex: 0, walletId: unresolvedOwnerId) + ]) + + let fetched = try XCTUnwrap(try fetchIdentity(ownIdentityId)) + XCTAssertNil(fetched.wallet) + XCTAssertTrue(fetched.isLocal, "clearing a link never demotes") + } + + /// Wallet B's valid relationship survives wallet A's manager + /// emitting the identity as observed (out-of-wallet is relative + /// to the EMITTING manager; the row is globally keyed). + func testObservedEntryPreservesAnotherWalletsLinkage() throws { + try insertWalletRow() + let otherWalletId = Data(repeating: 0xBB, count: 32) + let context = ModelContext(container) + let otherWallet = PersistentWallet(walletId: otherWalletId, network: .testnet) + context.insert(otherWallet) + let ownedByOther = PersistentIdentity( + identityId: observedIdentityId, + isLocal: true, + network: .testnet + ) + ownedByOther.wallet = otherWallet + context.insert(ownedByOther) + try context.save() + + applyIdentities([ + makeEntry(identityId: observedIdentityId, identityIndex: nil, walletId: nil) + ]) + + let row = try XCTUnwrap(try fetchIdentity(observedIdentityId)) + XCTAssertEqual(row.wallet?.walletId, otherWalletId) + XCTAssertTrue(row.isLocal) + } + + // MARK: Startup heal + + /// `loadWalletList()` promotes wallet-linked rows still carrying + /// the constant-`false` of the pre-fix persister; unlinked rows + /// (manual adds) are untouched in both directions. + func testLoadWalletListHealsWalletLinkedRows() throws { + try insertWalletRow() + let context = ModelContext(container) + let wallet = try XCTUnwrap( + try context.fetch(FetchDescriptor()).first + ) + let staleOwn = PersistentIdentity( + identityId: ownIdentityId, + isLocal: false, + network: .testnet + ) + staleOwn.wallet = wallet + context.insert(staleOwn) + let manualAdd = PersistentIdentity( + identityId: observedIdentityId, + isLocal: true, + network: .testnet + ) + context.insert(manualAdd) + try context.save() + + // The fixture wallet has no restorable accounts, so the + // returned list is empty — the heal runs regardless. + let result = handler.loadWalletList() + XCTAssertNil(result.entries) + XCTAssertFalse(result.errored) + + let healed = try XCTUnwrap(try fetchIdentity(ownIdentityId)) + XCTAssertTrue(healed.isLocal, "wallet-linked rows heal to local") + + let manual = try XCTUnwrap(try fetchIdentity(observedIdentityId)) + XCTAssertTrue(manual.isLocal, "manual adds are untouched by the heal") + } +}