Skip to content
Closed
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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ directly impact users rather than highlighting other crucial architectural updat

## [Unreleased]

### Added
- Server Setup UI reworked with Automatic/Manual connection mode.

## 3.3.1 build 1 (2026-05-06)

### Fixed
Expand Down Expand Up @@ -916,4 +919,3 @@ issue for more details.
--------
- Added SwiftGen templates for generating asset helper files.
- Added Code Review Guides, Changelog, pull request and issue templates, SwiftLint Rules

1 change: 1 addition & 0 deletions modules/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1199,6 +1199,7 @@ let package = Package(
"Generated",
"UserDefaults",
"UserPreferencesStorage",
"Utils",
.product(name: "ZcashLightClientKit", package: "zcash-swift-wallet-sdk"),
.product(name: "ComposableArchitecture", package: "swift-composable-architecture")
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ public struct UserPreferencesStorage {
/// The current key for exchange rate setup.
case ups_exchangeRate2
case ups_server
case ups_selectedServers
}

public enum UserPreferencesStorageError: Error {
case exchangeRate
case serverConfig
case selectedServersConfig
}

/// Default values for all preferences in case there is no value stored (counterparts to `Constants`)
Expand Down Expand Up @@ -63,6 +65,22 @@ public struct UserPreferencesStorage {
}
}

public var selectedServers: SelectedServersConfig? {
guard let contentData = userDefaults.objectForKey(Constants.ups_selectedServers.rawValue) as? Data else {
return nil
}
return try? JSONDecoder().decode(SelectedServersConfig.self, from: contentData)
}

public func setSelectedServers(_ config: SelectedServersConfig) throws {
do {
let contentData = try JSONEncoder().encode(config)
setValue(contentData, forKey: Constants.ups_selectedServers.rawValue)
} catch {
throw UserPreferencesStorageError.selectedServersConfig
}
}

/// Exchange rate API in the SDK uses TOR and eventually fetches the data from rate providers. This has to be opted in by a user, by default it's off.
public var exchangeRate: ExchangeRate? {
/// Removal of `legacy` key, see the comment of `Constants.ups_exchangeRate`
Expand Down Expand Up @@ -187,8 +205,31 @@ public extension UserPreferencesStorage {
guard let endpoint = ServerConfig.endpoint(for: string, streamingCallTimeoutInMillis: streamingCallTimeoutInMillis) else {
return nil
}

return ServerConfig(host: endpoint.host, port: endpoint.port, isCustom: isCustom)
}
}
}

// MARK: Connection Mode

public extension UserPreferencesStorage {
enum ConnectionMode: String, Codable, Equatable {
case automatic
case manual
}
}

// MARK: Selected Servers Config

public extension UserPreferencesStorage {
struct SelectedServersConfig: Equatable, Codable {
public let mode: ConnectionMode
public let servers: [ServerConfig]

public init(mode: ConnectionMode, servers: [ServerConfig]) {
self.mode = mode
self.servers = servers
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,8 @@ public struct UserPreferencesStorageClient {
public var exchangeRate: () -> UserPreferencesStorage.ExchangeRate?
public var setExchangeRate: (UserPreferencesStorage.ExchangeRate) throws -> Void

public var selectedServers: () -> UserPreferencesStorage.SelectedServersConfig?
public var setSelectedServers: (UserPreferencesStorage.SelectedServersConfig) throws -> Void

public var removeAll: () -> Void
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ extension UserPreferencesStorageClient: DependencyKey {
setServer: live.setServer(_:),
exchangeRate: { live.exchangeRate },
setExchangeRate: live.setExchangeRate(_:),
selectedServers: { live.selectedServers },
setSelectedServers: live.setSelectedServers(_:),
removeAll: live.removeAll
)
}()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ extension UserPreferencesStorageClient: TestDependencyKey {
setServer: mock.setServer(_:),
exchangeRate: { mock.exchangeRate },
setExchangeRate: mock.setExchangeRate(_:),
selectedServers: { mock.selectedServers },
setSelectedServers: mock.setSelectedServers(_:),
removeAll: mock.removeAll
)
}()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ extension ZcashSDKEnvironment {
if network == .mainnet {
servers.append(.custom)

let mainnetServers = ZcashSDKEnvironment.endpoints(skipDefault: true).map {
let mainnetServers = ZcashSDKEnvironment.endpoints(for: network, skipDefault: true).map {
Server.hardcoded("\($0.host):\($0.port)")
}

Expand All @@ -87,28 +87,37 @@ extension ZcashSDKEnvironment {
)
}

public static func endpoints(skipDefault: Bool = false) -> [LightWalletEndpoint] {
public static func endpoints(for network: NetworkType, skipDefault: Bool = false) -> [LightWalletEndpoint] {
if network == .testnet {
return skipDefault ? [] : [defaultEndpoint(for: network)]
}

let timeout = ZcashSDKConstants.streamingCallTimeoutInMillis
var result: [LightWalletEndpoint] = []

if !skipDefault {
result.append(LightWalletEndpoint(address: "us.zec.stardust.rest", port: 443))
result.append(LightWalletEndpoint(address: "us.zec.stardust.rest", port: 443, secure: true, streamingCallTimeoutInMillis: timeout))
}

result.append(
contentsOf: [
LightWalletEndpoint(address: "eu.zec.stardust.rest", port: 443),
LightWalletEndpoint(address: "eu2.zec.stardust.rest", port: 443),
LightWalletEndpoint(address: "jp.zec.stardust.rest", port: 443),
LightWalletEndpoint(address: "zec.rocks", port: 443),
LightWalletEndpoint(address: "na.zec.rocks", port: 443),
LightWalletEndpoint(address: "sa.zec.rocks", port: 443),
LightWalletEndpoint(address: "eu.zec.rocks", port: 443),
LightWalletEndpoint(address: "ap.zec.rocks", port: 443)
LightWalletEndpoint(address: "eu.zec.stardust.rest", port: 443, secure: true, streamingCallTimeoutInMillis: timeout),
LightWalletEndpoint(address: "eu2.zec.stardust.rest", port: 443, secure: true, streamingCallTimeoutInMillis: timeout),
LightWalletEndpoint(address: "jp.zec.stardust.rest", port: 443, secure: true, streamingCallTimeoutInMillis: timeout),
LightWalletEndpoint(address: "zec.rocks", port: 443, secure: true, streamingCallTimeoutInMillis: timeout),
LightWalletEndpoint(address: "na.zec.rocks", port: 443, secure: true, streamingCallTimeoutInMillis: timeout),
LightWalletEndpoint(address: "sa.zec.rocks", port: 443, secure: true, streamingCallTimeoutInMillis: timeout),
LightWalletEndpoint(address: "eu.zec.rocks", port: 443, secure: true, streamingCallTimeoutInMillis: timeout),
LightWalletEndpoint(address: "ap.zec.rocks", port: 443, secure: true, streamingCallTimeoutInMillis: timeout)
]
)

return result
}

public static func isKnownEndpoint(host: String, port: Int, network: NetworkType) -> Bool {
endpoints(for: network).contains { $0.host == host && $0.port == port }
}
}

@DependencyClient
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import ZcashLightClientKit

import UserPreferencesStorage
import UserDefaults
import Utils

extension ZcashSDKEnvironment {
public static func live(network: ZcashNetwork) -> Self {
Expand Down Expand Up @@ -38,17 +39,20 @@ extension ZcashSDKEnvironment {
extension ZcashSDKEnvironment {
public static func serverConfig(for network: NetworkType) -> UserPreferencesStorage.ServerConfig {
migrateVersion1IfNeeded()

initializeSelectedServersIfNeeded(for: network)

@Dependency(\.userStoredPreferences) var userStoredPreferences
if let selected = userStoredPreferences.selectedServers(),
selected.mode == .manual,
let first = selected.servers.first {
return normalizedStoredServerConfig(first)
}

guard let serverConfig = storedServerConfig() else {
return defaultEndpoint(for: network).serverConfig()
}

// Migrate lwdX.zcash-infra.com servers to custom
if serverConfig.host.hasSuffix(".zcash-infra.com") {
return UserPreferencesStorage.ServerConfig(host: serverConfig.host, port: serverConfig.port, isCustom: true)
}

return serverConfig

return normalizedStoredServerConfig(serverConfig)
}

static func migrateVersion1IfNeeded() {
Expand Down Expand Up @@ -95,6 +99,49 @@ extension ZcashSDKEnvironment {
}
}

/// On first launch (no selected servers config), initialize based on existing server preference:
/// - Custom server users: manual mode with their custom server (privacy)
/// - Known server users / new users: automatic mode (sends to all servers)
static func initializeSelectedServersIfNeeded(for network: NetworkType) {
@Dependency(\.userStoredPreferences) var userStoredPreferences

guard userStoredPreferences.selectedServers() == nil else { return }

if let existing = userStoredPreferences.server() {
let normalizedServer = normalizedStoredServerConfig(existing)

if normalizedServer.isCustom {
do {
try userStoredPreferences.setSelectedServers(.init(mode: .manual, servers: [normalizedServer]))
} catch {
LoggerProxy.error("[Migration] Failed to persist custom server selection: \(error)")
}
return
}
}

do {
try userStoredPreferences.setSelectedServers(.init(mode: .automatic, servers: []))
} catch {
LoggerProxy.error("[Migration] Failed to persist default server selection: \(error)")
}
}

static func normalizedStoredServerConfig(
_ serverConfig: UserPreferencesStorage.ServerConfig
) -> UserPreferencesStorage.ServerConfig {
// Preserve historical zcash-infra hosts as manual/custom selections.
if serverConfig.host.hasSuffix(".zcash-infra.com") {
return UserPreferencesStorage.ServerConfig(
host: serverConfig.host,
port: serverConfig.port,
isCustom: true
)
}

return serverConfig
}

static func storedServerConfig() -> UserPreferencesStorage.ServerConfig? {
@Dependency(\.userStoredPreferences) var userStoredPreferences
return userStoredPreferences.server()
Expand Down
3 changes: 2 additions & 1 deletion modules/Sources/Features/Root/RootInitialization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -385,7 +385,8 @@ extension Root {
.send(.batteryStateChanged(nil)),
.send(.observeTransactions),
.send(.observeShieldingProcessor),
.send(.observeTorInit)
.send(.observeTorInit),
.send(.benchmarkSyncEndpoint)
)

case .initialization(.loadedWalletAccounts(let walletAccounts)):
Expand Down
74 changes: 73 additions & 1 deletion modules/Sources/Features/Root/RootStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ public struct Root {
public var WalletConfigCancelId = UUID()
public var DidFinishLaunchingId = UUID()
public var CancelFlexaId = UUID()
public var serverBenchmarkCancelId = UUID()
public var shieldingProcessorCancelId = UUID()

@Shared(.inMemory(.addressBookContacts)) public var addressBookContacts: AddressBookContacts = .empty
Expand Down Expand Up @@ -230,6 +231,7 @@ public struct Root {
case walletBackupCoordFlow(WalletBackupCoordFlow.Action)
case torSetup(TorSetup.Action)
case backToHomeFromServerSwitchTapped
case benchmarkSyncEndpoint

// Transactions
case observeTransactions
Expand Down Expand Up @@ -425,6 +427,10 @@ public struct Root {
state.alert = nil
return .none

case .serverSetup(.setServerTapped):
// Cancel the startup benchmark so it can't overwrite the user's save
return .cancel(id: state.serverBenchmarkCancelId)

case .serverSetup:
return .none

Expand All @@ -444,12 +450,78 @@ public struct Root {
.cancel(id: state.CancelBatteryStateId),
.cancel(id: state.SynchronizerCancelId),
.cancel(id: state.WalletConfigCancelId),
.cancel(id: state.DidFinishLaunchingId)
.cancel(id: state.DidFinishLaunchingId),
.cancel(id: state.serverBenchmarkCancelId)
)

case .onboarding(.newWalletSuccessfulyCreated):
return .send(.initialization(.initializeSDK(.newWallet)))

case .benchmarkSyncEndpoint:
return .run { _ in
// Only benchmark in automatic mode — manual users chose their server explicitly
guard let config = userStoredPreferences.selectedServers(),
config.mode == .automatic else { return }

let network = zcashSDKEnvironment.network.networkType
let endpoints = ZcashSDKEnvironment.endpoints(for: network)

let bestServers = await sdkSynchronizer.evaluateBestOf(
endpoints, // candidates
300.0, // connectionTimeoutMs
5.0, // evaluationTimeoutSec (lightweight: 1 block, 5s cap)
1, // blocksToDownload
1, // topK
network
)

guard let best = bestServers.first else { return }

// Re-check mode — user may have switched to manual while benchmark was in flight
guard userStoredPreferences.selectedServers()?.mode == .automatic else { return }

let currentEndpoint = zcashSDKEnvironment.endpoint()
if best.host != currentEndpoint.host || best.port != currentEndpoint.port {
do {
try await sdkSynchronizer.switchToEndpoint(best)

// Re-check after async switch — if user saved manual mode while
// switchToEndpoint was in flight, revert to their chosen server.
// Read from selectedServers (not the legacy key) because the manual
// save path writes selectedServers first, so it's always up-to-date here.
guard userStoredPreferences.selectedServers()?.mode == .automatic else {
if let config = userStoredPreferences.selectedServers(),
config.mode == .manual,
let manualServer = config.servers.first {
let revert = manualServer.endpoint(
streamingCallTimeoutInMillis: ZcashSDKEnvironment.ZcashSDKConstants.streamingCallTimeoutInMillis
)
try? await sdkSynchronizer.switchToEndpoint(revert)
}
return
}

let isCustom = !ZcashSDKEnvironment.isKnownEndpoint(
host: best.host,
port: best.port,
network: network
)
let serverConfig = UserPreferencesStorage.ServerConfig(
host: best.host,
port: best.port,
isCustom: isCustom
)
// Only the legacy `server` key is updated here — `selectedServers.servers`
// stays empty in automatic mode by design. The active sync server is always
// derived from the legacy key; `selectedServers` only stores the mode.
try? userStoredPreferences.setServer(serverConfig)
} catch {
LoggerProxy.error("[Benchmark] Failed to switch endpoint: \(error)")
}
}
}
.cancellable(id: state.serverBenchmarkCancelId, cancelInFlight: true)

default: return .none
}
}
Expand Down
Loading