Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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 @@ -18,9 +18,14 @@ public final class PersistentWallet {
/// "is there a wallet on this chain yet" lookups) don't degrade
/// to a table scan.
#Index<PersistentWallet>([\.networkRaw])
#Unique<PersistentWallet>([\.walletId, \.networkRaw])

/// 32-byte wallet ID (SHA256 of root public key).
@Attribute(.unique) public var walletId: Data
/// 32-byte wallet ID (SHA256 of root public key). Not unique on
/// its own — the same seed yields the same `walletId` on every
/// network, so a wallet that exists on multiple chains has one
/// row per network. Uniqueness is the composite
/// `(walletId, networkRaw)` declared above.
public var walletId: Data
/// Network this wallet belongs to. `nil` means "not yet known" —
/// the row was created by a changeset before `persistWalletMetadata`
/// filled the network in. Views treat `nil` as unknown.
Expand Down Expand Up @@ -140,4 +145,14 @@ extension PersistentWallet {
public static func predicate(walletId: Data) -> Predicate<PersistentWallet> {
#Predicate<PersistentWallet> { $0.walletId == walletId }
}

public static func predicate(
walletId: Data,
network: Network
) -> Predicate<PersistentWallet> {
let networkRaw = network.rawValue
return #Predicate<PersistentWallet> {
$0.walletId == walletId && $0.networkRaw == networkRaw
}
}
Comment on lines +177 to +185
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 Nitpick: predicate(walletId:network:) is over-specified now that walletId is globally unique

With the latest delta's invariant that walletId is network-scoped and globally unique on its own (the network byte is folded into the digest, and the #Unique constraint on the model is now [\.walletId]), the composite predicate $0.walletId == walletId && $0.networkRaw == networkRaw can never narrow the result set further than the walletId clause alone, but it CAN spuriously return empty for rows whose networkRaw is nil — the property's own doc-comment (lines 47–49) explicitly permits pre-persistWalletMetadata rows to have networkRaw == nil. This is a low-impact mismatch with the model rather than a correctness break, and is worth flagging only because the just-rewritten walletId doc-comment advertises that the network is already baked into walletId — keeping a sibling lookup helper that still requires a networkRaw match invites confusion at future call sites. Consider either dropping the network clause from this predicate or removing the helper in favor of predicate(walletId:).

source: ['claude']

}
Original file line number Diff line number Diff line change
Expand Up @@ -619,10 +619,20 @@ public class PlatformWalletManager: ObservableObject {

try persistenceHandler.deleteWalletData(walletId: walletId)

let storage = WalletStorage()
// Delete metadata first so the mnemonic remains available for retry.
try storage.deleteMetadata(for: walletId)
try storage.deleteMnemonic(for: walletId)
// The mnemonic + metadata blobs in the Keychain are keyed by
// `walletId` alone and shared by every network the wallet
// lives on (same seed → same id on all chains). Only purge
// them once this was the wallet's LAST remaining
// `PersistentWallet` row — otherwise deleting the wallet from
// one network would orphan it on the others by destroying the
// recovery phrase they still rely on.
let remaining = try persistenceHandler.walletRowCountAcrossNetworks(walletId: walletId)
if remaining == 0 {
let storage = WalletStorage()
// Delete metadata first so the mnemonic remains available for retry.
try storage.deleteMetadata(for: walletId)
try storage.deleteMnemonic(for: walletId)
}
}

// MARK: - Per-wallet lookup
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -401,12 +401,12 @@ public class PlatformWalletPersistenceHandler {
/// stale post-deletion callbacks can't resurrect a wiped wallet.
private func ensureWalletRecord(walletId: Data) -> PersistentWallet {
let descriptor = FetchDescriptor<PersistentWallet>(
predicate: #Predicate { $0.walletId == walletId }
predicate: walletRecordPredicate(walletId: walletId)
)
if let existing = try? backgroundContext.fetch(descriptor).first {
return existing
}
let record = PersistentWallet(walletId: walletId, network: nil)
let record = PersistentWallet(walletId: walletId, network: self.network)
backgroundContext.insert(record)
return record
}
Expand All @@ -415,11 +415,27 @@ public class PlatformWalletPersistenceHandler {
/// when no row exists.
private func findWalletRecord(walletId: Data) -> PersistentWallet? {
let descriptor = FetchDescriptor<PersistentWallet>(
predicate: #Predicate { $0.walletId == walletId }
predicate: walletRecordPredicate(walletId: walletId)
)
return try? backgroundContext.fetch(descriptor).first
}

/// Predicate matching the `PersistentWallet` row owned by THIS
/// handler. A handler is constructed per-network, so when
/// `self.network` is set we scope to `(walletId, networkRaw)` —
/// otherwise the mainnet handler would find and overwrite the
/// devnet row (and vice versa) now that the same `walletId` can
/// have one row per network. When `self.network` is `nil` (the
/// advanced `configure(sdkPointer:network:nil)` path) we fall
/// back to walletId-only matching to preserve that behaviour.
private func walletRecordPredicate(walletId: Data) -> Predicate<PersistentWallet> {
if let network = self.network {
let networkRaw = network.rawValue
return #Predicate { $0.walletId == walletId && $0.networkRaw == networkRaw }
}
return #Predicate { $0.walletId == walletId }
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Look up a `PersistentWallet` to hang on
/// `PersistentIdentity.wallet`. Non-creating — returns `nil` if
/// no row exists (an identity may arrive before its owning
Expand All @@ -429,7 +445,7 @@ public class PlatformWalletPersistenceHandler {
private func fetchWalletForLink(walletId: Data?) -> PersistentWallet? {
guard let walletId else { return nil }
let descriptor = FetchDescriptor<PersistentWallet>(
predicate: #Predicate { $0.walletId == walletId }
predicate: walletRecordPredicate(walletId: walletId)
)
return try? backgroundContext.fetch(descriptor).first
}
Expand Down Expand Up @@ -1795,9 +1811,13 @@ public class PlatformWalletPersistenceHandler {
// 1. Resolve the wallet's network from SwiftData. We need it
// to feed `KeyDerivation.getIdentityAuthenticationPath`
// so the path chooses the right `coin_type` (mainnet vs
// testnet).
// testnet). Scope to THIS handler's network via
// `walletRecordPredicate` — the same `walletId` can now have
// a row per network, and a bare walletId-only fetch could
// resolve to a sibling network's row and derive the key on
// the wrong chain (unusable on-chain).
let walletDescriptor = FetchDescriptor<PersistentWallet>(
predicate: PersistentWallet.predicate(walletId: walletId)
predicate: walletRecordPredicate(walletId: walletId)
Comment on lines +1814 to +1820
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot May 31, 2026

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Move identity-key derivation behind a Rust/platform-wallet FFI entry point.

This method still pulls the mnemonic from Keychain, derives the seed, builds the DIP-9 path, and derives the private key in Swift. The new network-scoped lookup helps correctness, but the full mnemonic → seed → path → key pipeline is still implemented on the Swift side instead of persisting only Rust-returned key material.

As per coding guidelines, "Do not build derivation paths in Swift", "Do not orchestrate multi-step derivation pipelines in Swift (mnemonic → seed → path → key → store)", and "Identity key derivation and any action using a derived key must route through platform-wallet, not be re-implemented in Swift".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift`
around lines 1814 - 1820, The current method performs mnemonic→seed→DIP-9
path→private-key derivation in Swift (around the wallet lookup using
walletRecordPredicate and the FetchDescriptor<PersistentWallet> /
walletDescriptor), which violates the rule to keep derivation inside
platform-wallet; refactor by removing all mnemonic/seed/path/key derivation from
PlatformWalletPersistenceHandler and instead call a new or existing
platform-wallet FFI entry (exposed from Rust) that accepts the walletId (and
network scope) and returns the derived key material or a handle; update the code
that currently reads from Keychain and derives keys to call that FFI function
and persist only the returned key material, leaving walletRecordPredicate and
FetchDescriptor logic intact but delegating all derivation to the Rust
platform-wallet API.

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.

Acknowledged, not addressed in this PR. You're right that the mnemonic→seed→DIP-9 path→key pipeline belongs in platform-wallet per the swift-sdk CLAUDE.md rules. This code predates this PR (the PR only changed the wallet-row lookup from a bare predicate to walletRecordPredicate for network-correctness); pushing the whole derivation behind a Rust FFI entry point (platform_wallet_derive_identity_key or similar) is a cross-language refactor that belongs in its own PR with the swift-rust-ffi-engineer, not this Swift-only bugfix. Tracking as a follow-up.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@QuantumExplorer, understood — the scope of this PR is well-defined (network-scoped wallet-row lookup) and the derivation-pipeline refactor is legitimately a separate cross-language effort. Would you like me to open a GitHub issue to track the follow-up (platform_wallet_derive_identity_key FFI entry + removing the Swift-side mnemonic→seed→path→key pipeline from PlatformWalletPersistenceHandler)?


✏️ Learnings added
Learnt from: QuantumExplorer
Repo: dashpay/platform PR: 3772
File: packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift:1814-1820
Timestamp: 2026-06-01T07:59:33.680Z
Learning: In dashpay/platform, the Swift-side `deriveAndStoreIdentityKey` method in `PlatformWalletPersistenceHandler.swift` still implements the full mnemonic→seed→DIP-9 path→private-key derivation pipeline in Swift instead of delegating to a Rust FFI entry point. This is known technical debt that predates PR `#3772`; the planned fix is a new `platform_wallet_derive_identity_key` (or similar) FFI entry exposed from `rs-platform-wallet-ffi` that accepts walletId + network scope and returns the derived key material, removing the mnemonic/seed/path/key logic from Swift. This cross-language refactor is tracked as a follow-up PR requiring coordination with the swift-rust-ffi engineer and should not be flagged again on unrelated Swift-only PRs.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: QuantumExplorer
Repo: dashpay/platform PR: 3581
File: packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift:30-30
Timestamp: 2026-05-04T18:54:22.991Z
Learning: In the dashpay/platform repo, SwiftData schema changes in the Swift SDK are intentionally not migrated. When reviewing code in packages/swift-sdk/**/*.swift, do not flag schema updates (e.g., adding new Model types or changing relationships) for missing MigrationStage entries or missing VersionedSchema bumps—this repo expects dev stores to be rebuilt from scratch on schema changes, and SwiftData migrations are not required.

)
guard
let persistentWallet = try? backgroundContext.fetch(walletDescriptor).first
Expand Down Expand Up @@ -2594,11 +2614,25 @@ public class PlatformWalletPersistenceHandler {
}
}

public func identityIdsForWallet(walletId: Data) throws -> [Data] {
/// Count `PersistentWallet` rows for `walletId` across ALL
/// networks (deliberately ignores `self.network`). The mnemonic /
/// metadata in the Keychain are shared by every network's row, so
/// `deleteWallet` consults this after wiping its own network's row
/// to decide whether the shared Keychain material can be purged.
public func walletRowCountAcrossNetworks(walletId: Data) throws -> Int {
try onQueue {
let descriptor = FetchDescriptor<PersistentWallet>(
predicate: PersistentWallet.predicate(walletId: walletId)
)
return try backgroundContext.fetchCount(descriptor)
}
}

public func identityIdsForWallet(walletId: Data) throws -> [Data] {
try onQueue {
let descriptor = FetchDescriptor<PersistentWallet>(
predicate: walletRecordPredicate(walletId: walletId)
)
guard let walletRow = try backgroundContext.fetch(descriptor).first else {
return []
}
Expand All @@ -2611,7 +2645,7 @@ public class PlatformWalletPersistenceHandler {
try onQueue {
do {
let walletDescriptor = FetchDescriptor<PersistentWallet>(
predicate: PersistentWallet.predicate(walletId: walletId)
predicate: walletRecordPredicate(walletId: walletId)
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let walletRow = try backgroundContext.fetch(walletDescriptor).first
let walletNetwork = walletRow?.network
Expand Down Expand Up @@ -2689,32 +2723,63 @@ public class PlatformWalletPersistenceHandler {
try backgroundContext.save()
}

let txoDescriptor = FetchDescriptor<PersistentTxo>(
predicate: #Predicate<PersistentTxo> { $0.walletId == walletId }
)
for row in try backgroundContext.fetch(txoDescriptor) {
backgroundContext.delete(row)
// The txo / pending-input / asset-lock tables are keyed
// by the network-independent walletId (same mnemonic →
// same id on every network) and carry no network column,
// so their rows are shared by every network this wallet
// lives on. Only wipe them when this is the wallet's LAST
// remaining per-network row — otherwise deleting the
// wallet from one network would erase a sibling network's
// cached UTXOs / pending inputs / asset-lock state.
// (The walletRow itself, deleted below, IS network-scoped
// via `walletRecordPredicate`.) Counted before walletRow
// is removed, so `<= 1` means "this is the last one".
// Guard on `walletRow != nil`: if this handler doesn't
// own a row for `walletId` (asked to delete a wallet it
// doesn't have), a sibling network's row can still make
// the cross-network count 1 — which would wrongly read
// as "last row" and wipe the shared child tables out
// from under that other network. No owned row → never
// treat it as the last one.
let isLastNetworkRow: Bool
if walletRow != nil {
let siblingDescriptor = FetchDescriptor<PersistentWallet>(
predicate: #Predicate<PersistentWallet> { $0.walletId == walletId }
)
isLastNetworkRow =
((try? backgroundContext.fetchCount(siblingDescriptor)) ?? 0) <= 1
} else {
isLastNetworkRow = false
}

let pendingDescriptor = FetchDescriptor<PersistentPendingInput>(
predicate: #Predicate<PersistentPendingInput> { $0.walletId == walletId }
)
for row in try backgroundContext.fetch(pendingDescriptor) {
backgroundContext.delete(row)
}
if isLastNetworkRow {
let txoDescriptor = FetchDescriptor<PersistentTxo>(
predicate: #Predicate<PersistentTxo> { $0.walletId == walletId }
)
for row in try backgroundContext.fetch(txoDescriptor) {
backgroundContext.delete(row)
}

// `loadCachedAssetLocksOnQueue` rehydrates these rows on
// the wallet-load path back into the Rust-side
// `unused_asset_locks` map so an in-flight registration
// can resume across an app kill. Without this cleanup,
// delete-then-reimport of the same wallet would
// resurrect stale Pending / Resumable asset-lock state
// that the user thought they had wiped.
let assetLockDescriptor = FetchDescriptor<PersistentAssetLock>(
predicate: #Predicate<PersistentAssetLock> { $0.walletId == walletId }
)
for row in try backgroundContext.fetch(assetLockDescriptor) {
backgroundContext.delete(row)
let pendingDescriptor = FetchDescriptor<PersistentPendingInput>(
predicate: #Predicate<PersistentPendingInput> { $0.walletId == walletId }
)
for row in try backgroundContext.fetch(pendingDescriptor) {
backgroundContext.delete(row)
}

// `loadCachedAssetLocksOnQueue` rehydrates these rows on
// the wallet-load path back into the Rust-side
// `unused_asset_locks` map so an in-flight registration
// can resume across an app kill. Without this cleanup,
// delete-then-reimport of the same wallet would
// resurrect stale Pending / Resumable asset-lock state
// that the user thought they had wiped.
let assetLockDescriptor = FetchDescriptor<PersistentAssetLock>(
predicate: #Predicate<PersistentAssetLock> { $0.walletId == walletId }
)
for row in try backgroundContext.fetch(assetLockDescriptor) {
backgroundContext.delete(row)
}
}

if let walletRow = walletRow {
Expand Down Expand Up @@ -4029,8 +4094,14 @@ public class PlatformWalletPersistenceHandler {
/// `PersistentWallet` row. Returns `nil` if the wallet row
/// doesn't exist or its network hasn't been resolved yet.
private func walletNetwork(walletId: Data) -> Network? {
// Scope to this handler's network when one is set so a mnemonic
// that lives on multiple networks resolves to the row for THIS
// manager's network — not an arbitrary sibling row that would
// mis-stamp persisted sync state / identity / token writes and
// feed the wrong coin type into key derivation. Falls back to
// walletId-only when no network is set (legacy / no-container).
let descriptor = FetchDescriptor<PersistentWallet>(
predicate: #Predicate { $0.walletId == walletId }
predicate: walletRecordPredicate(walletId: walletId)
)
guard let wallet = try? backgroundContext.fetch(descriptor).first else {
return nil
Expand Down
14 changes: 9 additions & 5 deletions packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift
Original file line number Diff line number Diff line change
Expand Up @@ -287,15 +287,19 @@ public final class SDK: @unchecked Sendable {
// that toggle is off, the Rust side picks the canonical seed
// addresses for the network.
//
// `quorum_url` is forwarded whenever the UserDefaults override is
// set, regardless of network — supports custom mainnet/testnet
// shards and any future deployment that needs a non-default
// endpoint.
// `quorum_url` is gated identically: applied for devnet/regtest and
// under `useDockerSetup`, but NOT for plain mainnet/testnet. The
// `platformQuorumURL` UserDefault is only ever populated by the
// devnet-only Quorum URL field in Options, so forwarding it to a
// mainnet/testnet build leaked a devnet (often http) endpoint into a
// network whose Rust provider requires https — refusing to build the
// SDK. With the gate off, mainnet/testnet use the canonical quorum
// endpoints automatically.
let result: DashSDKResult
let useOverrideAddresses = network == .regtest
|| network == .devnet
|| UserDefaults.standard.bool(forKey: "useDockerSetup")
let overrideQuorumURL: String? = Self.platformQuorumURL
let overrideQuorumURL: String? = useOverrideAddresses ? Self.platformQuorumURL : nil

// Resolve the DAPI address list. Two paths:
//
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ struct ContentView: View {
.tag(RootTab.wallets)

// Tab 3: Identities
IdentitiesTabView()
IdentitiesTabView(network: platformState.currentNetwork)
.tabItem {
Label("Identities", systemImage: "person.crop.circle")
}
Expand Down Expand Up @@ -638,9 +638,11 @@ struct WalletsTabView: View {
}

struct IdentitiesTabView: View {
let network: Network

var body: some View {
NavigationStack {
IdentitiesContentView()
IdentitiesContentView(network: network)
}
}
}
Expand Down
Loading
Loading