Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
cea465e
fix(sdk): wallet-flow network fixes for SwiftExampleApp
QuantumExplorer May 31, 2026
ee59225
fix(sdk): per-network wallet persistence and add-to-network support
QuantumExplorer May 31, 2026
207b4f8
fix(sdk): address review — network-scope deletes, surface errors, rea…
QuantumExplorer May 31, 2026
65807e7
fix(sdk): drop identifier shortcut in hasAnyPrivateKey (review)
QuantumExplorer May 31, 2026
076c0a0
fix(sdk): network-scope deriveAndStoreIdentityKey wallet lookup (review)
QuantumExplorer May 31, 2026
cee4b2c
fix(sdk): surface partial-failure in multi-network wallet create (rev…
QuantumExplorer May 31, 2026
815751a
fix(sdk): correct two review-fix regressions (review)
QuantumExplorer Jun 1, 2026
14d353e
fix(sdk): network-scoped walletId closes cross-network cache leak (#2)
QuantumExplorer Jun 1, 2026
5e76921
test(sdk): cover all four networks in scoped-walletId stability test
QuantumExplorer Jun 1, 2026
7f14210
fix(sdk): add walletGroupId so Wallet Info finds sibling-network wallets
QuantumExplorer Jun 1, 2026
92b6b28
refactor(sdk): tighten PersistentWallet uniqueness to walletId alone
QuantumExplorer Jun 1, 2026
795284a
Merge remote-tracking branch 'origin/v3.1-dev' into fix/swift-wallet-…
QuantumExplorer Jun 6, 2026
4bdc604
refactor(sdk): drop dead predicate(walletId:network:) overload
QuantumExplorer Jun 7, 2026
dc55e6d
fix(sdk): use target-network birthHeight in enableNetwork metadata
QuantumExplorer Jun 7, 2026
4cf30a4
fix(swift): scope resumable asset locks to active network
QuantumExplorer Jun 8, 2026
1c0fb97
fix(sdk): classify duplicate-wallet errors by typed FFI code
QuantumExplorer Jun 8, 2026
21391d5
fix(sdk): emit WalletAlreadyExists on duplicate create; scope restora…
QuantumExplorer Jun 8, 2026
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 thread
QuantumExplorer marked this conversation as resolved.
Outdated
Comment thread
QuantumExplorer marked this conversation as resolved.
Outdated
}
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 thread
coderabbitai[bot] marked this conversation as resolved.
)
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,51 @@ public class PlatformWalletPersistenceHandler {
try backgroundContext.save()
}

let txoDescriptor = FetchDescriptor<PersistentTxo>(
predicate: #Predicate<PersistentTxo> { $0.walletId == walletId }
// 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".
let siblingDescriptor = FetchDescriptor<PersistentWallet>(
predicate: #Predicate<PersistentWallet> { $0.walletId == walletId }
)
for row in try backgroundContext.fetch(txoDescriptor) {
backgroundContext.delete(row)
}
let isLastNetworkRow =
((try? backgroundContext.fetchCount(siblingDescriptor)) ?? 0) <= 1

let pendingDescriptor = FetchDescriptor<PersistentPendingInput>(
predicate: #Predicate<PersistentPendingInput> { $0.walletId == walletId }
)
for row in try backgroundContext.fetch(pendingDescriptor) {
backgroundContext.delete(row)
}
if isLastNetworkRow {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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 +4082,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