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 @@ -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
Expand Down Expand Up @@ -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<PersistentIdentity> {
#Predicate<PersistentIdentity> { identity in
identity.wallet != nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

for identityId in removed {
Expand Down Expand Up @@ -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<PersistentIdentity>()
) else { return }
var healed = 0
for row in rows where row.wallet != nil && !row.isLocal {
row.isLocal = true

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Do not promote unverified legacy wallet links

Legacy observed identities mislinked by the old fallback satisfy this condition, so they become local before restore. The collision guard only helps when another row shares index 0; a sole mislink or evidence-less tie is still restored as wallet-owned and displayed as signable. Ambiguous legacy rows need ownership validation or quarantine before promotion/restore, plus a full loadWalletList-to-Rust restore regression.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 4516c46, split by harm:

  • "Displayed as signable" — the heal now promotes isLocal only when the linkage is corroborated by wallet-derivation key evidence (walletKeyEvidence: a PersistentPublicKey stamped with the wallet's id, which persistIdentityKeys writes only for DIP-9-derived keys and out-of-wallet entries never produce). A legacy mislink has no key rows, so it stays non-local and never presents as signable. testLoadWalletListPromotesOnlyEvidencedRowsAndNeverDemotes covers the mislink, the evidenced row, and the walletless-local row.
  • Sole mislink restored as wallet-owned — I tried the full quarantine you suggested (exclude every evidence-less linked row from the restore slice) and it broke legitimate restores: the contact- and payment-restore round-trip tests failed because collision-free rows without key stamps stopped restoring, which in a real older-era store would silently skip contact/payment rehydration. So the guard stays collision-scoped (evidence wins the index fight — that's the destructive case), and a sole index-0 mislink is instead displaced organically the next time the wallet's genuine identity loads: add_identity replaces the bucket slot, and the evidence-gated heal keeps the mislink from presenting as signable in the interim. The rationale is spelled out in restorableIdentities' doc.

On the full loadWalletList()-to-Rust restore regression: the Swift half (slice construction) is unit-covered; a true cross-FFI restore test needs the Rust wallet-manager harness and I'd suggest tracking it separately rather than blocking this fix.

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<WalletRestoreEntryFFI>?, 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Require signability as well as a loaded wallet for mutations

The PR now deliberately keeps a loaded watch-only wallet's identity at isLocal == false, but this eligibility check admits it based only on the wallet handle. The same loaded-wallet-only pattern remains on top-up/transfer/withdraw, DPNS marketplace/register, and DashPay profile edit/setup controls in IdentityDetailView. Those controls are exposed and then fail at signer/core-key resolution. Conversely, the token link checks only isLocal, admitting walletless imported-key identities into action views that require a managed wallet. Manager-backed mutations need both a loaded handle and operation-specific signing capability; read-only presentation can remain separate.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 98bf816 — one canMutate(as:) gate (isLocal && hasLoadedWallet) now fronts every manager-backed mutation: DashPay-tab eligibility, top-up/transfer/withdraw, DPNS register + marketplace, profile Edit/Set-up, and the token-actions link (which, as you note, previously admitted walletless imported-key identities with no manager handle). Read-only presentation — profile card, token rows, key list — stays ungated.

}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)"
)
}
}
}
Expand Down
Loading
Loading