Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,4 @@ TESTFLIGHT_NOTES.md

# Local build-claim signing key (never commit)
.build-claim/
.env.mainnet
4 changes: 4 additions & 0 deletions App/Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@
#endif
<key>CosignRelayURL</key>
<string>$(COSIGN_RELAY_URL)</string>
<key>CosignDevnetRelayURL</key>
<string>$(COSIGN_DEVNET_RELAY_URL)</string>
<key>CosignMainnetRelayURL</key>
<string>$(COSIGN_MAINNET_RELAY_URL)</string>
<key>CosignEnvironment</key>
<string>$(COSIGN_ENV)</string>
<key>CFBundleShortVersionString</key>
Expand Down
39 changes: 28 additions & 11 deletions App/Sources/CosignApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,16 @@ struct CosignApp: App {
let demoMode: CosignDemoMode?
let demoFixture: CosignDemoFixture?

@State private var networkSettings = NetworkSettingsStore()
@State private var networkSettings: NetworkSettingsStore
@State private var squadsService: SquadsService?

init() {
let demoMode = CosignDemoMode.launchConfiguration()
self.demoMode = demoMode
demoFixture = demoMode.map { demoMode in
demoFixture = demoMode.map { mode in
CosignDemoFixture.profile(
demoMode.profile,
memberAddresses: CosignDemoSigners.memberAddresses(for: demoMode.profile)
mode.profile,
memberAddresses: CosignDemoSigners.memberAddresses(for: mode.profile)
)
}

Expand All @@ -28,28 +29,44 @@ struct CosignApp: App {
} catch {
fatalError("Failed to initialize SwiftData container: \(error)")
}

let store = NetworkSettingsStore()
if demoMode == nil {
let signerCount = (try? container.mainContext.fetchCount(FetchDescriptor<RegisteredSigner>())) ?? 0
store.resolveInitialNetwork(hasExistingSigners: signerCount > 0)
}
_networkSettings = State(initialValue: store)
}

var body: some Scene {
WindowGroup {
let environment = demoFixture.map(demoEnvironment) ?? networkSettings.environment

let service = squadsService ?? makeService(environment)
ContentView()
.preferredColorScheme(.dark)
.environment(networkSettings)
.environment(networkSettings.networkHealth)
.environment(\.indexerEnvironment, environment)
.environment(\.squadsService, SquadsService(
environment: environment,
demoFixture: demoFixture,
demoBroadcastMode: demoBroadcastMode(for: demoMode),
healthReporter: networkSettings.networkHealth.reporter()
))
.environment(\.squadsService, service)
.environment(\.cosignDemoMode, demoMode)
.onAppear { if squadsService == nil { squadsService = service } }
.onChange(of: networkSettings.selectedNetwork) { _, _ in
guard demoFixture == nil else { return }
squadsService = makeService(networkSettings.environment)
}
}
.modelContainer(container)
}

private func makeService(_ environment: IndexerEnvironment) -> SquadsService {
SquadsService(
environment: environment,
demoFixture: demoFixture,
demoBroadcastMode: demoBroadcastMode(for: demoMode),
healthReporter: networkSettings.networkHealth.reporter()
)
}

private func demoEnvironment(fixture: CosignDemoFixture) -> IndexerEnvironment {
IndexerEnvironment(
rpcURL: URL(string: "https://demo.cosign.local")!,
Expand Down
15 changes: 15 additions & 0 deletions Modules/Core/Sources/Network.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
public enum Network: String, Codable, Sendable, CaseIterable {
case mainnet
case devnet

public var displayName: String {
switch self {
case .mainnet: "Mainnet"
case .devnet: "Devnet"
}
}

public var isMainnet: Bool {
self == .mainnet
}
}
16 changes: 16 additions & 0 deletions Modules/Core/Tests/NetworkTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import Testing
@testable import Core

struct NetworkTests {
@Test func rawValuesAreStable() {
#expect(Network.mainnet.rawValue == "mainnet")
#expect(Network.devnet.rawValue == "devnet")
}

@Test func displayNamesAndFlag() {
#expect(Network.mainnet.displayName == "Mainnet")
#expect(Network.devnet.displayName == "Devnet")
#expect(Network.mainnet.isMainnet == true)
#expect(Network.devnet.isMainnet == false)
}
}
15 changes: 11 additions & 4 deletions Modules/Indexer/Sources/CosignBuildEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,24 @@ import Foundation
public struct CosignBuildEnvironment: Sendable {
public let relayURL: URL?
public let environmentName: String
public let devnetRelayURL: URL?
public let mainnetRelayURL: URL?

public static func current(
infoDictionary: [String: Any] = Bundle.main.infoDictionary ?? [:]
) -> CosignBuildEnvironment {
let rawURL = (infoDictionary["CosignRelayURL"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
func url(_ key: String) -> URL? {
let raw = (infoDictionary[key] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return raw.isEmpty ? nil : URL(string: raw)
}
let environment = (infoDictionary["CosignEnvironment"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return CosignBuildEnvironment(
relayURL: rawURL.isEmpty ? nil : URL(string: rawURL),
environmentName: environment
relayURL: url("CosignRelayURL"),
environmentName: environment,
devnetRelayURL: url("CosignDevnetRelayURL"),
mainnetRelayURL: url("CosignMainnetRelayURL")
)
}
}
46 changes: 46 additions & 0 deletions Modules/Indexer/Sources/NetworkConfig.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import Core
import Foundation

public struct NetworkConfig: Sendable {
public let relayURL: URL
public let explorerRPCURL: URL
public let supportsAirdrop: Bool

public static func config(for network: Network, build: CosignBuildEnvironment) -> NetworkConfig {
switch network {
case .devnet:
NetworkConfig(
relayURL: build.devnetRelayURL ?? IndexerEnvironment.devnetRPCURL,
explorerRPCURL: IndexerEnvironment.devnetRPCURL,
supportsAirdrop: true
)
case .mainnet:
NetworkConfig(
relayURL: build.mainnetRelayURL ?? IndexerEnvironment.mainnetRPCURL,
explorerRPCURL: IndexerEnvironment.mainnetRPCURL,
supportsAirdrop: false
)
}
}
}

public extension IndexerEnvironment {
static func forNetwork(
_ network: Network,
build: CosignBuildEnvironment = .current(),
healthReporter: NetworkHealthReporter?
) -> IndexerEnvironment {
let cfg = NetworkConfig.config(for: network, build: build)
return IndexerEnvironment(
rpcURL: cfg.relayURL,
relay: HTTPRelayClient(
baseURL: cfg.relayURL,
capabilities: RelayCapability.enhancedFeatures,
healthReporter: healthReporter
),
webSocketURL: SolanaWebSocketEndpoint.relayWebSocketURL(for: cfg.relayURL),
explorerRPCURL: cfg.explorerRPCURL,
supportsAirdrop: cfg.supportsAirdrop
)
}
}
95 changes: 75 additions & 20 deletions Modules/Indexer/Sources/NetworkSettingsStore.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Core
import Foundation
import Observation

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

public let networkHealth = NetworkHealth()

/// The active Solana network. Switch with `switch(to:)`.
public private(set) var selectedNetwork: Network

/// True when a custom relay URL is saved to the Keychain, causing
/// `environment` to route through that URL rather than the network default.
public private(set) var hasCustomRelayOverride: Bool

private let rpcURLKeychain: SecureNetworkURLKeychain
private let defaultRPCURL: URL
private let networkDefaults: UserDefaults
private static let selectedNetworkKey = "cosign.selectedNetwork"

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

init(rpcURLKeychain: SecureNetworkURLKeychain) {
init(rpcURLKeychain: SecureNetworkURLKeychain, networkDefaults: UserDefaults = .standard) {
let fallback = NetworkSettingsStore.defaultRPCURL
defaultRPCURL = fallback
self.rpcURLKeychain = rpcURLKeychain
self.networkDefaults = networkDefaults

var hadKeychainURL = false
do {
if let storedRPCURL = try rpcURLKeychain.load() {
rpcURL = try NetworkSettingsStore.validatedRPCURL(from: storedRPCURL)
hadKeychainURL = true
} else {
rpcURL = fallback
}
} catch {
rpcURL = fallback
loadErrorMessage = "Failed to load the saved relay endpoint from the Keychain."
}
hasCustomRelayOverride = hadKeychainURL

// Use the persisted network if one exists; otherwise default to .mainnet
// until resolveInitialNetwork(_:) is called with signer context.
let stored = networkDefaults.string(forKey: NetworkSettingsStore.selectedNetworkKey)
.flatMap(Network.init(rawValue:))
selectedNetwork = stored ?? .mainnet
if !hadKeychainURL {
rpcURL = NetworkConfig.config(for: selectedNetwork, build: .current()).relayURL
}
}

/// Always a relay client. RPC flows through the relay's Solana passthrough,
/// live updates through its `/ws` proxy, pricing/decoding through cosign/v1.
/// Call once after the SwiftData container is available. If no network
/// preference has been persisted yet, computes the migration default
/// (mainnet for fresh installs, devnet for installs with existing signers)
/// and persists it.
public func resolveInitialNetwork(hasExistingSigners: Bool) {
guard networkDefaults.string(forKey: Self.selectedNetworkKey) == nil else { return }
let resolved = Self.defaultNetwork(hasExistingSigners: hasExistingSigners, stored: nil)
selectedNetwork = resolved
networkDefaults.set(resolved.rawValue, forKey: Self.selectedNetworkKey)
if !hasCustomRelayOverride {
rpcURL = NetworkConfig.config(for: resolved, build: .current()).relayURL
}
}

/// Migration default: stored preference wins; otherwise mainnet for fresh
/// installs and devnet for installs that already have signers.
public static func defaultNetwork(hasExistingSigners: Bool, stored: Network?) -> Network {
if let stored { return stored }
return hasExistingSigners ? .devnet : .mainnet
}

/// Always a relay client. When a custom relay URL is active, routes through
/// that URL; otherwise derives everything from `selectedNetwork`.
/// In both branches, `explorerRPCURL` and airdrop follow `selectedNetwork`.
public var environment: IndexerEnvironment {
IndexerEnvironment(
rpcURL: rpcURL,
relay: HTTPRelayClient(
baseURL: rpcURL,
capabilities: RelayCapability.enhancedFeatures,
healthReporter: networkHealth.reporter()
),
webSocketURL: SolanaWebSocketEndpoint.relayWebSocketURL(for: rpcURL),
explorerRPCURL: explorerRPCURL
)
let cfg = NetworkConfig.config(for: selectedNetwork, build: .current())
if hasCustomRelayOverride {
return IndexerEnvironment(
rpcURL: rpcURL,
relay: HTTPRelayClient(
baseURL: rpcURL,
capabilities: RelayCapability.enhancedFeatures,
healthReporter: networkHealth.reporter()
),
webSocketURL: SolanaWebSocketEndpoint.relayWebSocketURL(for: rpcURL),
explorerRPCURL: cfg.explorerRPCURL,
supportsAirdrop: cfg.supportsAirdrop
)
}
return IndexerEnvironment.forNetwork(selectedNetwork, healthReporter: networkHealth.reporter())
}

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

public var redactedRPCURLString: String {
Expand All @@ -78,13 +131,15 @@ public final class NetworkSettingsStore {
let url = try NetworkSettingsStore.validatedRPCURL(from: value)
try rpcURLKeychain.save(url.absoluteString)
rpcURL = url
hasCustomRelayOverride = true
pendingRPCURLDraft = nil
loadErrorMessage = nil
}

public func resetRPCURL() throws {
try rpcURLKeychain.delete()
rpcURL = defaultRPCURL
rpcURL = NetworkConfig.config(for: selectedNetwork, build: .current()).relayURL
hasCustomRelayOverride = false
pendingRPCURLDraft = nil
loadErrorMessage = nil
}
Expand Down
19 changes: 19 additions & 0 deletions Modules/Indexer/Tests/CosignBuildEnvironmentTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import XCTest
@testable import Indexer

final class CosignBuildEnvironmentTests: XCTestCase {
func testReadsBothRelayURLs() {
let env = CosignBuildEnvironment.current(infoDictionary: [
"CosignDevnetRelayURL": "https://cosign-relay-devnet.fly.dev",
"CosignMainnetRelayURL": "https://cosign-relay-mainnet.fly.dev"
])
XCTAssertEqual(env.devnetRelayURL?.absoluteString, "https://cosign-relay-devnet.fly.dev")
XCTAssertEqual(env.mainnetRelayURL?.absoluteString, "https://cosign-relay-mainnet.fly.dev")
}

func testMissingKeysAreNil() {
let env = CosignBuildEnvironment.current(infoDictionary: [:])
XCTAssertNil(env.devnetRelayURL)
XCTAssertNil(env.mainnetRelayURL)
}
}
31 changes: 31 additions & 0 deletions Modules/Indexer/Tests/NetworkConfigTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import XCTest
@testable import Core
@testable import Indexer

final class NetworkConfigTests: XCTestCase {
private let build = CosignBuildEnvironment.current(infoDictionary: [
"CosignDevnetRelayURL": "https://cosign-relay-devnet.fly.dev",
"CosignMainnetRelayURL": "https://cosign-relay-mainnet.fly.dev"
])

func testDevnet() {
let cfg = NetworkConfig.config(for: .devnet, build: build)
XCTAssertEqual(cfg.relayURL.absoluteString, "https://cosign-relay-devnet.fly.dev")
XCTAssertEqual(cfg.explorerRPCURL, IndexerEnvironment.devnetRPCURL)
XCTAssertTrue(cfg.supportsAirdrop)
}

func testMainnet() {
let cfg = NetworkConfig.config(for: .mainnet, build: build)
XCTAssertEqual(cfg.relayURL.absoluteString, "https://cosign-relay-mainnet.fly.dev")
XCTAssertEqual(cfg.explorerRPCURL, IndexerEnvironment.mainnetRPCURL)
XCTAssertFalse(cfg.supportsAirdrop)
}

func testEnvironmentFactoryWiresRelayAndExplorer() {
let env = IndexerEnvironment.forNetwork(.mainnet, build: build, healthReporter: nil)
XCTAssertEqual(env.rpcURL.absoluteString, "https://cosign-relay-mainnet.fly.dev")
XCTAssertEqual(env.explorerRPCURL, IndexerEnvironment.mainnetRPCURL)
XCTAssertFalse(env.supportsAirdrop)
}
}
Loading
Loading