Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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 @@ -2594,11 +2610,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 +2641,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
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
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ struct CreateWalletView: View {
@Environment(\.dismiss) var dismiss
@Environment(\.modelContext) private var modelContext
@EnvironmentObject var walletManager: PlatformWalletManager
@EnvironmentObject var walletManagerStore: WalletManagerStore
@EnvironmentObject var platformState: AppState

@State private var walletLabel: String = ""
Expand Down Expand Up @@ -313,94 +314,102 @@ struct CreateWalletView: View {
print("PIN length: \(walletPin.count)")
print("Import option enabled: \(showImportOption)")

// Determine primary network to create the wallet in (SDK enforces unique wallet per mnemonic)
let selectedNetworks: [Network] = [
createForMainnet ? Network.mainnet : nil,
createForTestnet ? Network.testnet : nil,
(createForDevnet && shouldShowDevnet) ? Network.devnet : nil,
(createForRegtest && shouldShowRegtest) ? Network.regtest : nil,
].compactMap { $0 }

guard let platformNetwork = selectedNetworks.first else {
guard !selectedNetworks.isEmpty else {
struct MissingNetwork: LocalizedError {
var errorDescription: String? { "No network selected" }
}
throw MissingNetwork()
}

// Create exactly one wallet via PlatformWalletManager.
// The Rust-side wallet creation emits
// `persistWalletMetadata` + `setWalletName`, which
// the persister callback translates into a
// `PersistentWallet` SwiftData row — no separate
// HDWallet mirror to maintain. We only have to
// patch `isImported` after-the-fact because that
// flag is UI-cosmetic and the persister doesn't
// know about it.
// Create the wallet in EVERY ticked network. Each
// network has its own `PlatformWalletManager` (the
// Rust manager is network-locked at construction and
// stamps its own network onto the wallet), so routing
// through the active manager alone would ignore the
// passed `network`. `backgroundManager(for:)` returns
// the warm cached manager for the active network and
// builds one on demand for the others. `walletId` is
// network-independent, so the Keychain mnemonic +
// metadata are written once and the `isImported` flag
// is stamped on every per-network row.
try await MainActor.run {
let managed = try walletManager.createWallet(
mnemonic: mnemonicPhrase,
network: platformNetwork,
name: walletLabel
)
var createdWalletId: Data?
for net in selectedNetworks {
do {
let mgr = try walletManagerStore.backgroundManager(for: net)
let managed = try mgr.createWallet(
mnemonic: mnemonicPhrase,
network: net,
name: walletLabel
)
createdWalletId = managed.walletId
} catch {
// An "already exists" throw for one network
// (wallet was previously created there) is
// non-fatal — keep creating the others.
SDKLogger.error(
"Wallet creation skipped for \(net.displayName): \(error.localizedDescription)"
)
}
Comment on lines +365 to +386
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.

🟡 Suggestion: Brittle substring match on "already exists" to classify a benign error

The branch classifying the per-network failure as benign relies on a case-insensitive substring search for "already exists" against error.localizedDescription. This matches the current PlatformWalletError::WalletAlreadyExists Display string, but it's an implicit coupling: a tweak to the Rust error message, a localization layer, or any wrapping that prepends context can silently break the classification — partial creates would then be reported as full failures (or vice versa). The same pattern is repeated in WalletDetailView.swift:752. Prefer exposing a typed FFI error code / dedicated Swift error case (e.g. PlatformWalletError.walletAlreadyExists) and switching on it, with the string match kept only as a last-resort fallback.

source: ['claude']

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.

Agreed it's brittle. Acknowledged but not changing in this PR — the proper fix is a typed FFI error code / dedicated PlatformWalletError.walletAlreadyExists case to switch on, which is an FFI surface change (rs-platform-wallet-ffi) beyond this Swift-only bugfix. The substring match (here and WalletDetailView) is the current best available signal; tracking the typed-error work as a follow-up.

Comment on lines +364 to +386
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.

🟡 Suggestion: Benign 'wallet already exists' classification still relies on a substring match against a Rust display string, now duplicated into WalletDetailView

Carried-forward from cee4b2c — re-validated and STILL VALID. The per-network create loop classifies a benign duplicate by error.localizedDescription.range(of: "already exists", options: .caseInsensitive) (L371) instead of a typed error. This branch controls whether presentNetworks includes the network and whether the all-failed AllNetworksFailed path fires — i.e. whether this PR's freshly-fixed 'no wallet could be created' regression stays fixed. Any rewording on the Rust side (PlatformWalletError::WalletAlreadyExists display impl), localization wrapper, or future error chaining silently flips a benign case into the failures bucket and re-surfaces the misleading 'Wallet could not be created on any selected network' alert.

This PR also propagates the same heuristic to WalletDetailView.swift:752 in the new enableNetwork(_:) button — the per-network + action — so a single wording change now breaks two user-visible flows. Replace with a typed FFI error code / dedicated Swift error case for WalletAlreadyExists (e.g. matching on PlatformWalletError.walletAlreadyExists rather than the string) and keep the substring fallback only as a defense-in-depth check.

source: ['claude', 'codex']

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.

Same finding as the existing thread on this file (#discussion_r3331111688) — acknowledged and deferred. The robust fix is a typed PlatformWalletError.walletAlreadyExists case to switch on (an rs-platform-wallet-ffi error-surface change), with the substring match kept only as a defense-in-depth fallback. Tracking as a follow-up rather than expanding this Swift-only bugfix PR; the substring check is the current best available signal in both CreateWalletView and WalletDetailView.

Comment on lines +365 to +386
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.

🟡 Suggestion: Duplicate-wallet classification still relies on substring-matching a Rust display string

Carried forward from prior reviews (cee4b2c, 815751a) and still valid at 14d353e. The per-network create loop classifies a benign duplicate by error.localizedDescription.range(of: "already exists", options: .caseInsensitive) rather than a typed error. That branch decides whether an already-present wallet is recorded as a no-op or surfaced via the failures array as a user-visible "Wallet could not be created". Any wording, capitalisation, localisation, or FFI-wrapper text change to PlatformWalletError's Display impl breaks this classification silently. Expose a typed Rust variant (e.g. PlatformWalletError::WalletAlreadyRegistered) across the FFI so Swift can match by case instead of substring.

source: ['claude', 'codex']

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, deferring (same as the existing thread on this file). The robust fix is a typed PlatformWalletError.walletAlreadyExists to switch on, which is an rs-platform-wallet-ffi error-surface change beyond this Swift-side bugfix. Tracking as a follow-up; the substring match is the current best available signal.

}

guard let walletId = createdWalletId else {
struct AllNetworksFailed: LocalizedError {
var errorDescription: String? {
"Wallet could not be created on any selected network."
}
}
throw AllNetworksFailed()
}

// Persist the mnemonic in the iOS Keychain keyed
// by walletId so multiple wallets coexist and the
// recovery flow can enumerate all of them on
// launch. Best-effort — failure here doesn't
// block wallet creation.
// by walletId (network-independent) so the
// recovery flow can enumerate wallets on launch.
// Best-effort — failure here doesn't block.
let storage = WalletStorage()
do {
try storage.storeMnemonic(
mnemonicPhrase,
for: managed.walletId
)
try storage.storeMnemonic(mnemonicPhrase, for: walletId)
} catch {
SDKLogger.error(
"Failed to persist mnemonic to keychain: \(error.localizedDescription)"
)
}
// Stamp the `isImported` flag on the
// just-created PersistentWallet row. The
// persister callback runs synchronously from
// `walletManager.createWallet` via the
// background context; SwiftData's
// `autosaveEnabled = true` on that context
// propagates the row into the main context
// before this fetch runs. If the row somehow
// isn't there yet, the flag stays `false`
// (the default on `PersistentWallet`) — a
// cosmetic miss, not a correctness issue.
let walletIdMatch = managed.walletId

// Stamp `isImported` on every per-network row for
// this walletId. The persister callbacks run
// synchronously from `createWallet` via the
// background contexts; autosave propagates the
// rows into the main context before this fetch.
let descriptor = FetchDescriptor<PersistentWallet>(
predicate: #Predicate { $0.walletId == walletIdMatch }
predicate: PersistentWallet.predicate(walletId: walletId)
)
let row = try? modelContext.fetch(descriptor).first
if let row = row {
let rows = (try? modelContext.fetch(descriptor)) ?? []
for row in rows {
row.isImported = showImportOption
}
if !rows.isEmpty {
try? modelContext.save()
}
// Mirror the user-typed name + the networks the
// user explicitly ticked + the SPV-tip-derived
// birth height into the keychain alongside the
// mnemonic. Read back by the orphan-mnemonic
// recovery flow so a wipe + reinstall restores
// the original label / networks / birth height
// instead of resurrecting the wallet on testnet
// with a synthetic genesis.
//
// `selectedNetworks` carries every network the
// user ticked even though `walletManager` only
// currently consumes the first; persisting the
// full list now means the multi-network TODO on
// the Rust side won't need a metadata migration.

// Mirror the name + ticked networks + birth height
// into the keychain alongside the mnemonic so an
// orphan-recovery after a wipe restores the
// original label / networks / birth height.
do {
let metadata = WalletKeychainMetadata(
name: walletLabel,
walletDescription: nil,
networks: selectedNetworks.map { $0.networkName },
birthHeight: row?.birthHeight
birthHeight: rows.first?.birthHeight
)
try storage.setMetadata(metadata, for: managed.walletId)
try storage.setMetadata(metadata, for: walletId)
} catch {
SDKLogger.error(
"Failed to persist wallet metadata to keychain: \(error.localizedDescription)"
Expand All @@ -409,14 +418,19 @@ struct CreateWalletView: View {
dismiss()
}

print("=== WALLET CREATION SUCCESS - Created 1 wallet for \(platformNetwork.displayName) ===")
print("=== WALLET CREATION SUCCESS - networks: \(selectedNetworks.map { $0.displayName }) ===")
} catch {
print("=== WALLET CREATION ERROR ===")
print("Error: \(error)")

await MainActor.run {
self.error = error
isCreating = false
// Pop the pushed `SeedBackupView` so the error alert
// (bound to this view) is actually visible — otherwise
// the backup screen sits on top with its submit button
// stuck disabled and no feedback.
showBackupScreen = false
}
}
}
Expand Down
Loading
Loading