-
Notifications
You must be signed in to change notification settings - Fork 56
fix(swift-sdk): isLocal = mine-or-tracked; promote wallet identities, fix observed-entry mislinking #4375
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix(swift-sdk): isLocal = mine-or-tracked; promote wallet identities, fix observed-entry mislinking #4375
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<PersistentIdentity>() | ||
| ) else { return } | ||
| var healed = 0 | ||
| for row in rows where row.wallet != nil && !row.isLocal { | ||
| row.isLocal = true | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Addressed in 4516c46, split by harm:
On the full |
||
| 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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 98bf816 — one |
||
| } | ||
| } | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.