Skip to content

Commit 0b88eee

Browse files
authored
Add devnet/mainnet network switcher (combo app) (#39)
Ship one app that carries both the devnet and mainnet relay configs with a runtime switcher, replacing the separate devnet bundle. - Network model: a first-class Network enum (Core) is the source of truth; NetworkConfig derives the relay, Explorer, websocket, and airdrop support per network. Both relay URLs are baked into the app Info.plist. - Reactive swap: NetworkSettingsStore owns the selected network and derives the IndexerEnvironment from it. Switching rebuilds SquadsService (a fresh instance flushes the squad, proposal, and activity caches) and realigns the relay URL. Signers are network-agnostic and persist across a switch. - Migration: fresh installs default to mainnet; installs that already have signers stay on devnet. The stored preference is authoritative thereafter. - Switcher UX: an amber-weighted devnet/mainnet control in Settings, Network with the self-hosted relay beneath it. Switching to mainnet asks for a caution confirmation (a distinct amber sheet, not the red destructive one), and the signing sheets carry a network row that marks mainnet as real funds. A persistent network chip, the home footer, and the relay-card cluster label all follow the selected network. - Targets: the Cosign target becomes the combo (both URLs, neutral icon); the separate CosignDevnet target is retired. - Mainnet relay: adds core/fly.mainnet.toml (cosign-relay-mainnet); the Helius mainnet upstream is a Fly secret, never in the repo. - Marketing: reposition the landing page from devnet-only to the combo app, with a "one app, two networks" section and a safety story built on on-device keys, decode-before-signing, and verifiable builds. - Canonical marketing: the devnet relay's landing paths (/, /privacy, assets) 301-redirect to the single canonical host via COSIGN_LANDING_REDIRECT, so the marketing site has one home; the relay's RPC role for devnet Solana is unaffected and the mainnet relay stays canonical.
1 parent 3c6d193 commit 0b88eee

34 files changed

Lines changed: 924 additions & 149 deletions

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,4 @@ TESTFLIGHT_NOTES.md
5151

5252
# Local build-claim signing key (never commit)
5353
.build-claim/
54+
.env.mainnet

‎App/Resources/Info.plist‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@
3333
#endif
3434
<key>CosignRelayURL</key>
3535
<string>$(COSIGN_RELAY_URL)</string>
36+
<key>CosignDevnetRelayURL</key>
37+
<string>$(COSIGN_DEVNET_RELAY_URL)</string>
38+
<key>CosignMainnetRelayURL</key>
39+
<string>$(COSIGN_MAINNET_RELAY_URL)</string>
3640
<key>CosignEnvironment</key>
3741
<string>$(COSIGN_ENV)</string>
3842
<key>CFBundleShortVersionString</key>

‎App/Sources/CosignApp.swift‎

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,16 @@ struct CosignApp: App {
1111
let demoMode: CosignDemoMode?
1212
let demoFixture: CosignDemoFixture?
1313

14-
@State private var networkSettings = NetworkSettingsStore()
14+
@State private var networkSettings: NetworkSettingsStore
15+
@State private var squadsService: SquadsService?
1516

1617
init() {
1718
let demoMode = CosignDemoMode.launchConfiguration()
1819
self.demoMode = demoMode
19-
demoFixture = demoMode.map { demoMode in
20+
demoFixture = demoMode.map { mode in
2021
CosignDemoFixture.profile(
21-
demoMode.profile,
22-
memberAddresses: CosignDemoSigners.memberAddresses(for: demoMode.profile)
22+
mode.profile,
23+
memberAddresses: CosignDemoSigners.memberAddresses(for: mode.profile)
2324
)
2425
}
2526

@@ -28,28 +29,44 @@ struct CosignApp: App {
2829
} catch {
2930
fatalError("Failed to initialize SwiftData container: \(error)")
3031
}
32+
33+
let store = NetworkSettingsStore()
34+
if demoMode == nil {
35+
let signerCount = (try? container.mainContext.fetchCount(FetchDescriptor<RegisteredSigner>())) ?? 0
36+
store.resolveInitialNetwork(hasExistingSigners: signerCount > 0)
37+
}
38+
_networkSettings = State(initialValue: store)
3139
}
3240

3341
var body: some Scene {
3442
WindowGroup {
3543
let environment = demoFixture.map(demoEnvironment) ?? networkSettings.environment
36-
44+
let service = squadsService ?? makeService(environment)
3745
ContentView()
3846
.preferredColorScheme(.dark)
3947
.environment(networkSettings)
4048
.environment(networkSettings.networkHealth)
4149
.environment(\.indexerEnvironment, environment)
42-
.environment(\.squadsService, SquadsService(
43-
environment: environment,
44-
demoFixture: demoFixture,
45-
demoBroadcastMode: demoBroadcastMode(for: demoMode),
46-
healthReporter: networkSettings.networkHealth.reporter()
47-
))
50+
.environment(\.squadsService, service)
4851
.environment(\.cosignDemoMode, demoMode)
52+
.onAppear { if squadsService == nil { squadsService = service } }
53+
.onChange(of: networkSettings.selectedNetwork) { _, _ in
54+
guard demoFixture == nil else { return }
55+
squadsService = makeService(networkSettings.environment)
56+
}
4957
}
5058
.modelContainer(container)
5159
}
5260

61+
private func makeService(_ environment: IndexerEnvironment) -> SquadsService {
62+
SquadsService(
63+
environment: environment,
64+
demoFixture: demoFixture,
65+
demoBroadcastMode: demoBroadcastMode(for: demoMode),
66+
healthReporter: networkSettings.networkHealth.reporter()
67+
)
68+
}
69+
5370
private func demoEnvironment(fixture: CosignDemoFixture) -> IndexerEnvironment {
5471
IndexerEnvironment(
5572
rpcURL: URL(string: "https://demo.cosign.local")!,

‎Modules/Core/Sources/Network.swift‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
public enum Network: String, Codable, Sendable, CaseIterable {
2+
case mainnet
3+
case devnet
4+
5+
public var displayName: String {
6+
switch self {
7+
case .mainnet: "Mainnet"
8+
case .devnet: "Devnet"
9+
}
10+
}
11+
12+
public var isMainnet: Bool {
13+
self == .mainnet
14+
}
15+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import Testing
2+
@testable import Core
3+
4+
struct NetworkTests {
5+
@Test func rawValuesAreStable() {
6+
#expect(Network.mainnet.rawValue == "mainnet")
7+
#expect(Network.devnet.rawValue == "devnet")
8+
}
9+
10+
@Test func displayNamesAndFlag() {
11+
#expect(Network.mainnet.displayName == "Mainnet")
12+
#expect(Network.devnet.displayName == "Devnet")
13+
#expect(Network.mainnet.isMainnet == true)
14+
#expect(Network.devnet.isMainnet == false)
15+
}
16+
}

‎Modules/Indexer/Sources/CosignBuildEnvironment.swift‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,24 @@ import Foundation
88
public struct CosignBuildEnvironment: Sendable {
99
public let relayURL: URL?
1010
public let environmentName: String
11+
public let devnetRelayURL: URL?
12+
public let mainnetRelayURL: URL?
1113

1214
public static func current(
1315
infoDictionary: [String: Any] = Bundle.main.infoDictionary ?? [:]
1416
) -> CosignBuildEnvironment {
15-
let rawURL = (infoDictionary["CosignRelayURL"] as? String)?
16-
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
17+
func url(_ key: String) -> URL? {
18+
let raw = (infoDictionary[key] as? String)?
19+
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
20+
return raw.isEmpty ? nil : URL(string: raw)
21+
}
1722
let environment = (infoDictionary["CosignEnvironment"] as? String)?
1823
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
1924
return CosignBuildEnvironment(
20-
relayURL: rawURL.isEmpty ? nil : URL(string: rawURL),
21-
environmentName: environment
25+
relayURL: url("CosignRelayURL"),
26+
environmentName: environment,
27+
devnetRelayURL: url("CosignDevnetRelayURL"),
28+
mainnetRelayURL: url("CosignMainnetRelayURL")
2229
)
2330
}
2431
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import Core
2+
import Foundation
3+
4+
public struct NetworkConfig: Sendable {
5+
public let relayURL: URL
6+
public let explorerRPCURL: URL
7+
public let supportsAirdrop: Bool
8+
9+
public static func config(for network: Network, build: CosignBuildEnvironment) -> NetworkConfig {
10+
switch network {
11+
case .devnet:
12+
NetworkConfig(
13+
relayURL: build.devnetRelayURL ?? IndexerEnvironment.devnetRPCURL,
14+
explorerRPCURL: IndexerEnvironment.devnetRPCURL,
15+
supportsAirdrop: true
16+
)
17+
case .mainnet:
18+
NetworkConfig(
19+
relayURL: build.mainnetRelayURL ?? IndexerEnvironment.mainnetRPCURL,
20+
explorerRPCURL: IndexerEnvironment.mainnetRPCURL,
21+
supportsAirdrop: false
22+
)
23+
}
24+
}
25+
}
26+
27+
public extension IndexerEnvironment {
28+
static func forNetwork(
29+
_ network: Network,
30+
build: CosignBuildEnvironment = .current(),
31+
healthReporter: NetworkHealthReporter?
32+
) -> IndexerEnvironment {
33+
let cfg = NetworkConfig.config(for: network, build: build)
34+
return IndexerEnvironment(
35+
rpcURL: cfg.relayURL,
36+
relay: HTTPRelayClient(
37+
baseURL: cfg.relayURL,
38+
capabilities: RelayCapability.enhancedFeatures,
39+
healthReporter: healthReporter
40+
),
41+
webSocketURL: SolanaWebSocketEndpoint.relayWebSocketURL(for: cfg.relayURL),
42+
explorerRPCURL: cfg.explorerRPCURL,
43+
supportsAirdrop: cfg.supportsAirdrop
44+
)
45+
}
46+
}

‎Modules/Indexer/Sources/NetworkSettingsStore.swift‎

Lines changed: 75 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import Core
12
import Foundation
23
import Observation
34

@@ -15,8 +16,17 @@ public final class NetworkSettingsStore {
1516

1617
public let networkHealth = NetworkHealth()
1718

19+
/// The active Solana network. Switch with `switch(to:)`.
20+
public private(set) var selectedNetwork: Network
21+
22+
/// True when a custom relay URL is saved to the Keychain, causing
23+
/// `environment` to route through that URL rather than the network default.
24+
public private(set) var hasCustomRelayOverride: Bool
25+
1826
private let rpcURLKeychain: SecureNetworkURLKeychain
1927
private let defaultRPCURL: URL
28+
private let networkDefaults: UserDefaults
29+
private static let selectedNetworkKey = "cosign.selectedNetwork"
2030

2131
public static var defaultRPCURL: URL {
2232
CosignBuildEnvironment.current().relayURL ?? IndexerEnvironment.devnetRPCURL
@@ -26,44 +36,87 @@ public final class NetworkSettingsStore {
2636
self.init(rpcURLKeychain: .rpcURL)
2737
}
2838

29-
init(rpcURLKeychain: SecureNetworkURLKeychain) {
39+
init(rpcURLKeychain: SecureNetworkURLKeychain, networkDefaults: UserDefaults = .standard) {
3040
let fallback = NetworkSettingsStore.defaultRPCURL
3141
defaultRPCURL = fallback
3242
self.rpcURLKeychain = rpcURLKeychain
43+
self.networkDefaults = networkDefaults
3344

45+
var hadKeychainURL = false
3446
do {
3547
if let storedRPCURL = try rpcURLKeychain.load() {
3648
rpcURL = try NetworkSettingsStore.validatedRPCURL(from: storedRPCURL)
49+
hadKeychainURL = true
3750
} else {
3851
rpcURL = fallback
3952
}
4053
} catch {
4154
rpcURL = fallback
4255
loadErrorMessage = "Failed to load the saved relay endpoint from the Keychain."
4356
}
57+
hasCustomRelayOverride = hadKeychainURL
58+
59+
// Use the persisted network if one exists; otherwise default to .mainnet
60+
// until resolveInitialNetwork(_:) is called with signer context.
61+
let stored = networkDefaults.string(forKey: NetworkSettingsStore.selectedNetworkKey)
62+
.flatMap(Network.init(rawValue:))
63+
selectedNetwork = stored ?? .mainnet
64+
if !hadKeychainURL {
65+
rpcURL = NetworkConfig.config(for: selectedNetwork, build: .current()).relayURL
66+
}
4467
}
4568

46-
/// Always a relay client. RPC flows through the relay's Solana passthrough,
47-
/// live updates through its `/ws` proxy, pricing/decoding through cosign/v1.
69+
/// Call once after the SwiftData container is available. If no network
70+
/// preference has been persisted yet, computes the migration default
71+
/// (mainnet for fresh installs, devnet for installs with existing signers)
72+
/// and persists it.
73+
public func resolveInitialNetwork(hasExistingSigners: Bool) {
74+
guard networkDefaults.string(forKey: Self.selectedNetworkKey) == nil else { return }
75+
let resolved = Self.defaultNetwork(hasExistingSigners: hasExistingSigners, stored: nil)
76+
selectedNetwork = resolved
77+
networkDefaults.set(resolved.rawValue, forKey: Self.selectedNetworkKey)
78+
if !hasCustomRelayOverride {
79+
rpcURL = NetworkConfig.config(for: resolved, build: .current()).relayURL
80+
}
81+
}
82+
83+
/// Migration default: stored preference wins; otherwise mainnet for fresh
84+
/// installs and devnet for installs that already have signers.
85+
public static func defaultNetwork(hasExistingSigners: Bool, stored: Network?) -> Network {
86+
if let stored { return stored }
87+
return hasExistingSigners ? .devnet : .mainnet
88+
}
89+
90+
/// Always a relay client. When a custom relay URL is active, routes through
91+
/// that URL; otherwise derives everything from `selectedNetwork`.
92+
/// In both branches, `explorerRPCURL` and airdrop follow `selectedNetwork`.
4893
public var environment: IndexerEnvironment {
49-
IndexerEnvironment(
50-
rpcURL: rpcURL,
51-
relay: HTTPRelayClient(
52-
baseURL: rpcURL,
53-
capabilities: RelayCapability.enhancedFeatures,
54-
healthReporter: networkHealth.reporter()
55-
),
56-
webSocketURL: SolanaWebSocketEndpoint.relayWebSocketURL(for: rpcURL),
57-
explorerRPCURL: explorerRPCURL
58-
)
94+
let cfg = NetworkConfig.config(for: selectedNetwork, build: .current())
95+
if hasCustomRelayOverride {
96+
return IndexerEnvironment(
97+
rpcURL: rpcURL,
98+
relay: HTTPRelayClient(
99+
baseURL: rpcURL,
100+
capabilities: RelayCapability.enhancedFeatures,
101+
healthReporter: networkHealth.reporter()
102+
),
103+
webSocketURL: SolanaWebSocketEndpoint.relayWebSocketURL(for: rpcURL),
104+
explorerRPCURL: cfg.explorerRPCURL,
105+
supportsAirdrop: cfg.supportsAirdrop
106+
)
107+
}
108+
return IndexerEnvironment.forNetwork(selectedNetwork, healthReporter: networkHealth.reporter())
59109
}
60110

61-
/// Cluster hint for Explorer links — derived from the build env, since the
62-
/// relay host itself doesn't reveal the cluster.
63-
private var explorerRPCURL: URL {
64-
CosignBuildEnvironment.current().environmentName.lowercased() == "devnet"
65-
? IndexerEnvironment.devnetRPCURL
66-
: IndexerEnvironment.mainnetRPCURL
111+
/// Switches the active network, drops any custom relay override, and
112+
/// persists the new preference. No-op if already on the requested network.
113+
public func `switch`(to network: Network) {
114+
guard network != selectedNetwork else { return }
115+
selectedNetwork = network
116+
networkDefaults.set(network.rawValue, forKey: Self.selectedNetworkKey)
117+
try? rpcURLKeychain.delete()
118+
hasCustomRelayOverride = false
119+
rpcURL = NetworkConfig.config(for: network, build: .current()).relayURL
67120
}
68121

69122
public var redactedRPCURLString: String {
@@ -78,13 +131,15 @@ public final class NetworkSettingsStore {
78131
let url = try NetworkSettingsStore.validatedRPCURL(from: value)
79132
try rpcURLKeychain.save(url.absoluteString)
80133
rpcURL = url
134+
hasCustomRelayOverride = true
81135
pendingRPCURLDraft = nil
82136
loadErrorMessage = nil
83137
}
84138

85139
public func resetRPCURL() throws {
86140
try rpcURLKeychain.delete()
87-
rpcURL = defaultRPCURL
141+
rpcURL = NetworkConfig.config(for: selectedNetwork, build: .current()).relayURL
142+
hasCustomRelayOverride = false
88143
pendingRPCURLDraft = nil
89144
loadErrorMessage = nil
90145
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import XCTest
2+
@testable import Indexer
3+
4+
final class CosignBuildEnvironmentTests: XCTestCase {
5+
func testReadsBothRelayURLs() {
6+
let env = CosignBuildEnvironment.current(infoDictionary: [
7+
"CosignDevnetRelayURL": "https://cosign-relay-devnet.fly.dev",
8+
"CosignMainnetRelayURL": "https://cosign-relay-mainnet.fly.dev"
9+
])
10+
XCTAssertEqual(env.devnetRelayURL?.absoluteString, "https://cosign-relay-devnet.fly.dev")
11+
XCTAssertEqual(env.mainnetRelayURL?.absoluteString, "https://cosign-relay-mainnet.fly.dev")
12+
}
13+
14+
func testMissingKeysAreNil() {
15+
let env = CosignBuildEnvironment.current(infoDictionary: [:])
16+
XCTAssertNil(env.devnetRelayURL)
17+
XCTAssertNil(env.mainnetRelayURL)
18+
}
19+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import XCTest
2+
@testable import Core
3+
@testable import Indexer
4+
5+
final class NetworkConfigTests: XCTestCase {
6+
private let build = CosignBuildEnvironment.current(infoDictionary: [
7+
"CosignDevnetRelayURL": "https://cosign-relay-devnet.fly.dev",
8+
"CosignMainnetRelayURL": "https://cosign-relay-mainnet.fly.dev"
9+
])
10+
11+
func testDevnet() {
12+
let cfg = NetworkConfig.config(for: .devnet, build: build)
13+
XCTAssertEqual(cfg.relayURL.absoluteString, "https://cosign-relay-devnet.fly.dev")
14+
XCTAssertEqual(cfg.explorerRPCURL, IndexerEnvironment.devnetRPCURL)
15+
XCTAssertTrue(cfg.supportsAirdrop)
16+
}
17+
18+
func testMainnet() {
19+
let cfg = NetworkConfig.config(for: .mainnet, build: build)
20+
XCTAssertEqual(cfg.relayURL.absoluteString, "https://cosign-relay-mainnet.fly.dev")
21+
XCTAssertEqual(cfg.explorerRPCURL, IndexerEnvironment.mainnetRPCURL)
22+
XCTAssertFalse(cfg.supportsAirdrop)
23+
}
24+
25+
func testEnvironmentFactoryWiresRelayAndExplorer() {
26+
let env = IndexerEnvironment.forNetwork(.mainnet, build: build, healthReporter: nil)
27+
XCTAssertEqual(env.rpcURL.absoluteString, "https://cosign-relay-mainnet.fly.dev")
28+
XCTAssertEqual(env.explorerRPCURL, IndexerEnvironment.mainnetRPCURL)
29+
XCTAssertFalse(env.supportsAirdrop)
30+
}
31+
}

0 commit comments

Comments
 (0)