diff --git a/packages/rs-sdk-ffi/src/sdk.rs b/packages/rs-sdk-ffi/src/sdk.rs index efd6d3aecfe..7cccf2532f4 100644 --- a/packages/rs-sdk-ffi/src/sdk.rs +++ b/packages/rs-sdk-ffi/src/sdk.rs @@ -334,10 +334,47 @@ pub unsafe extern "C" fn dash_sdk_create_trusted(config: *const DashSDKConfig) - "dash_sdk_create_trusted: creating trusted context provider" ); - // Create trusted context provider - // For regtest, use the quorum sidecar at localhost:22444 (dashmate Docker default) - let is_local = matches!(network, Network::Regtest); - let trusted_provider = if is_local { + // Create trusted context provider. Resolution order for the quorum + // lookup base URL: + // 1. Caller-provided `config.quorum_url` (highest priority — required + // for devnet, also usable for non-default mainnet/testnet shards). + // 2. Regtest fallback to the local quorum sidecar `127.0.0.1:22444` + // (the dashmate Docker default). + // 3. Network-derived default (mainnet/testnet only). + let explicit_quorum_url: Option = if config.quorum_url.is_null() { + None + } else { + match unsafe { CStr::from_ptr(config.quorum_url) }.to_str() { + Ok(s) if !s.is_empty() => Some(s.to_string()), + Ok(_) => None, + Err(e) => { + return DashSDKResult::error(DashSDKError::new( + DashSDKErrorCode::InvalidParameter, + format!("Invalid quorum URL string: {}", e), + )) + } + } + }; + let trusted_provider = if let Some(quorum_url) = explicit_quorum_url { + info!( + quorum_url = %quorum_url, + "dash_sdk_create_trusted: using caller-provided quorum URL" + ); + match rs_sdk_trusted_context_provider::TrustedHttpContextProvider::new_with_url( + network, + quorum_url, + std::num::NonZeroUsize::new(100).unwrap(), + ) { + Ok(provider) => Arc::new(provider), + Err(e) => { + error!(error = %e, "dash_sdk_create_trusted: failed to create context provider from override URL"); + return DashSDKResult::error(DashSDKError::new( + DashSDKErrorCode::InternalError, + format!("Failed to create context provider: {}", e), + )); + } + } + } else if matches!(network, Network::Regtest) { info!("dash_sdk_create_trusted: using local quorum sidecar for regtest"); match rs_sdk_trusted_context_provider::TrustedHttpContextProvider::new_with_url( network, @@ -376,10 +413,11 @@ pub unsafe extern "C" fn dash_sdk_create_trusted(config: *const DashSDKConfig) - } }; - // Parse DAPI addresses - for trusted setup, we always need real addresses + // Parse DAPI addresses - for trusted setup, we always need real addresses. + // Devnet/regtest have no built-in defaults; callers must supply + // `dapi_addresses` (and typically `quorum_url`) for those networks. let builder = if config.dapi_addresses.is_null() { info!("dash_sdk_create_trusted: no DAPI addresses provided, using defaults for network"); - // Use default addresses for the network match network { Network::Testnet => SdkBuilder::new_testnet(), Network::Mainnet => SdkBuilder::new_mainnet(), @@ -600,6 +638,7 @@ pub unsafe extern "C" fn dash_sdk_create_with_callbacks( skip_asset_lock_proof_verification: config_ref.skip_asset_lock_proof_verification, request_retry_count: config_ref.request_retry_count, request_timeout_ms: config_ref.request_timeout_ms, + quorum_url: config_ref.quorum_url, platform_version: config_ref.platform_version, }, context_provider: context_provider_handle, diff --git a/packages/rs-sdk-ffi/src/token/claim.rs b/packages/rs-sdk-ffi/src/token/claim.rs index dd2f96a77c7..6eb5dec4277 100644 --- a/packages/rs-sdk-ffi/src/token/claim.rs +++ b/packages/rs-sdk-ffi/src/token/claim.rs @@ -203,6 +203,7 @@ mod tests { skip_asset_lock_proof_verification: false, request_retry_count: 3, request_timeout_ms: 5000, + quorum_url: ptr::null(), platform_version: 0, }; diff --git a/packages/rs-sdk-ffi/src/token/emergency_action.rs b/packages/rs-sdk-ffi/src/token/emergency_action.rs index a93ba9fd874..6e4b7f3adc2 100644 --- a/packages/rs-sdk-ffi/src/token/emergency_action.rs +++ b/packages/rs-sdk-ffi/src/token/emergency_action.rs @@ -210,6 +210,7 @@ mod tests { skip_asset_lock_proof_verification: false, request_retry_count: 3, request_timeout_ms: 5000, + quorum_url: ptr::null(), platform_version: 0, }; diff --git a/packages/rs-sdk-ffi/src/token/freeze.rs b/packages/rs-sdk-ffi/src/token/freeze.rs index 3e260081b62..2bd16e076ae 100644 --- a/packages/rs-sdk-ffi/src/token/freeze.rs +++ b/packages/rs-sdk-ffi/src/token/freeze.rs @@ -212,6 +212,7 @@ mod tests { skip_asset_lock_proof_verification: false, request_retry_count: 3, request_timeout_ms: 5000, + quorum_url: ptr::null(), platform_version: 0, }; diff --git a/packages/rs-sdk-ffi/src/types.rs b/packages/rs-sdk-ffi/src/types.rs index 2f2ad8efc94..efdaa4e45be 100644 --- a/packages/rs-sdk-ffi/src/types.rs +++ b/packages/rs-sdk-ffi/src/types.rs @@ -77,6 +77,22 @@ pub struct DashSDKConfig { pub request_retry_count: u32, /// Timeout for requests in milliseconds pub request_timeout_ms: u64, + /// Optional override for the trusted-context-provider quorum lookup base URL + /// (e.g., `"https://quorums.devnet.example.networks.dash.org"` or + /// `"http://127.0.0.1:22444"`). When null/empty, the provider uses the + /// default endpoint derived from `network` (mainnet/testnet only — devnet + /// needs an explicit URL, regtest defaults to the local sidecar). + /// + /// **Only honored on the `dash_sdk_create_trusted` path** — that's the + /// path that builds a `TrustedHttpContextProvider`, which is the + /// component that actually performs quorum lookups. The callback-based + /// path (`dash_sdk_create_with_callbacks`) uses `CallbackContextProvider` + /// and ignores this field entirely; non-null values there are silently + /// dropped. + /// + /// Same lifetime contract as `dapi_addresses`: borrowed, copied + /// immediately, caller may free after the FFI call returns. + pub quorum_url: *const c_char, /// Pin to a specific Dash Platform protocol version. /// `0` keeps the SDK default (auto-detect / latest); any non-zero value /// is forwarded to `SdkBuilder::with_version` and rejected if unknown. diff --git a/packages/rs-sdk-ffi/tests/context_provider_test.rs b/packages/rs-sdk-ffi/tests/context_provider_test.rs index cda74eed121..f1ce62dc4b1 100644 --- a/packages/rs-sdk-ffi/tests/context_provider_test.rs +++ b/packages/rs-sdk-ffi/tests/context_provider_test.rs @@ -85,6 +85,7 @@ mod tests { skip_asset_lock_proof_verification: false, request_retry_count: 3, request_timeout_ms: 30000, + quorum_url: ptr::null(), platform_version: 0, }; diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift index f351698aec7..f7fe4f55275 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift @@ -65,6 +65,12 @@ public enum LoggingPreferences { public enum SDKLogger { public static func log(_ message: String, minimumLevel level: LoggingPreset = .medium) { guard LoggingPreferences.allows(level) else { return } + // Mirror to NSLog (unified logging) in addition to stdout so + // `xcrun simctl spawn booted log stream` and Console.app see + // the message even when no Xcode debugger is attached. The + // `print` path is preserved because the dev loop still wants + // stdout for in-Xcode use; NSLog goes to os_log. + NSLog("%@", message) Swift.print(message) } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index 1e39669550d..f10331e22c8 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -89,6 +89,142 @@ public final class SDK: @unchecked Sendable { return "http://127.0.0.1:2443" } + /// Optional caller-provided base URL for the trusted-context-provider's + /// quorum lookups. Read from UserDefaults key `platformQuorumURL`. + /// Required to connect to devnets (no built-in default exists on the + /// Rust side); also usable to override mainnet/testnet for staging + /// shards. Returns nil when unset/empty. + private static var platformQuorumURL: String? { + guard + let value = UserDefaults.standard.string(forKey: "platformQuorumURL"), + !value.isEmpty + else { return nil } + return value + } + + /// Synchronously fetch `{quorumBase}/masternodes` and return the + /// raw `data` array. Both the DAPI list and the SPV peer list are + /// derived from this — DAPI takes `:`, SPV + /// takes the verbatim `address` field (`:`). + /// + /// Returns nil on any failure (timeout, JSON shape mismatch, etc.). + /// Filters to `status == "ENABLED" && version_check == "success"` + /// to match the Rust trusted-context provider's active-node policy + /// (see `rs-sdk-trusted-context-provider/src/provider.rs`). Without + /// the `version_check` filter, nodes the quorum service has + /// already flagged as incompatible would be seeded into both the + /// DAPI fan-out and the SPV peer list, undermining the + /// self-healing rebuild this enables. + /// + /// `public` because both the SDK init (DAPI fan-out) and the + /// SwiftExampleApp's SPV start path call it independently against + /// the same endpoint — each caller pays its own round-trip, with + /// no shared cache. An SDK rebuild on devnet therefore performs + /// two `/masternodes` fetches; if that becomes a problem, the + /// expectation is that callers add a short-lived cache locally + /// (or refactor to share one through the SDK). + public static func discoverActiveMasternodes( + quorumBase: String + ) -> [(spvPeer: String, dapiUrl: String)]? { + guard + var components = URLComponents(string: quorumBase), + let scheme = components.scheme, + !scheme.isEmpty + else { return nil } + if components.path.hasSuffix("/") { + components.path = String(components.path.dropLast()) + } + components.path += "/masternodes" + guard let url = components.url else { return nil } + + var request = URLRequest(url: url) + request.timeoutInterval = 5.0 + request.httpMethod = "GET" + + // Reference-typed box for the response so the completion + // handler can safely store into it from URLSession's worker + // thread without violating Swift 6 strict-concurrency capture + // rules (which forbid mutating a captured `var Data?` from a + // concurrently-executing closure). The semaphore guarantees + // we only read `box.data` after the closure has run to + // completion, so the cross-thread access is data-race-free. + final class ResponseBox: @unchecked Sendable { + var data: Data? + } + let box = ResponseBox() + let semaphore = DispatchSemaphore(value: 0) + let task = URLSession.shared.dataTask(with: request) { data, _, _ in + box.data = data + semaphore.signal() + } + task.resume() + _ = semaphore.wait(timeout: .now() + .seconds(6)) + guard let data = box.data else { + task.cancel() + return nil + } + + struct Envelope: Decodable { + let success: Bool + let data: [Masternode] + } + struct Masternode: Decodable { + let address: String // "ip:CoreP2PPort" + let status: String + // Optional to match the Rust trusted-context provider, which + // tolerates entries missing `platformHTTPPort` and substitutes + // a per-network default. Requiring this would make a single + // misbehaving JSON entry fail the whole decode (Decodable is + // all-or-nothing per object), nuking devnet auto-discovery. + // + // Note JSON wire keys are camelCase (`platformHTTPPort`, + // `versionCheck`) — Rust renames its snake_case fields with + // `#[serde(rename = ...)]` to produce that on the wire. Swift's + // default `Decodable` synthesis matches property name → JSON + // key literally, so no `CodingKeys` is needed here as long as + // these property names match the wire keys verbatim. + let platformHTTPPort: UInt16? + // Same `versionCheck` field the Rust provider filters on. + // Optional because older quorum-list-server builds may omit it; + // callers below treat missing as "not success" (i.e. excluded). + let versionCheck: String? + } + + guard + let env = try? JSONDecoder().decode(Envelope.self, from: data), + env.success + else { return nil } + + // Conservative default — matches the Rust trusted-context + // provider's fallback when the entry omits `platform_http_port`. + let defaultDapiPort: UInt16 = 443 + let active: [(String, String)] = env.data.compactMap { mn in + guard mn.status == "ENABLED", mn.versionCheck == "success" else { return nil } + let host = mn.address.split(separator: ":").first.map(String.init) ?? mn.address + let dapiPort = mn.platformHTTPPort ?? defaultDapiPort + return (mn.address, "https://\(host):\(dapiPort)") + } + return active.isEmpty ? nil : active + } + + /// Synchronously fetch `{quorumBase}/masternodes` and build a + /// comma-separated DAPI URL list (`https://:,…`). + /// Returns nil on any error (network failure, JSON shape mismatch, + /// timeout). Used by `init(network:)` to auto-populate the DAPI + /// fan-out list on devnet when the user hasn't supplied one + /// manually — saves the "you must paste 13 URLs" UX. + /// + /// Filters to `status == "ENABLED"` so down / banned nodes don't + /// pollute the AddressList (the DAPI client would ban them on + /// first request anyway, but skipping them up front speeds the + /// first sync). + private static func discoverDAPIAddresses(quorumBase: String) -> String? { + guard let active = discoverActiveMasternodes(quorumBase: quorumBase) else { + return nil + } + return active.map(\.dapiUrl).joined(separator: ",") + } + /// Create a new SDK instance with trusted setup /// /// This uses a trusted context provider that fetches quorum keys and @@ -98,34 +234,83 @@ public final class SDK: @unchecked Sendable { var config = DashSDKConfig() config.network = network.ffiValue config.dapi_addresses = nil + config.quorum_url = nil config.skip_asset_lock_proof_verification = false config.request_retry_count = 1 config.request_timeout_ms = 8000 // 8 seconds config.platform_version = platformVersion // 0 = SDK default (auto-detect) - // Create SDK with trusted setup — Rust side auto-detects local/regtest - // and uses the quorum sidecar at localhost:22444 instead of remote endpoints. + // Create SDK with trusted setup. DAPI / quorum-URL overrides come from + // UserDefaults and apply on: // - // Regtest has no remote DAPI defaults on the Rust side, so it - // *must* be constructed with a local DAPI address regardless of - // the user-facing `useDockerSetup` toggle. Without this, building - // a regtest SDK from a context where the toggle has been - // auto-disabled (e.g. orphan-mnemonic recovery routing wallets to - // their original network from a non-regtest active state) fails - // with `DAPI addresses not available for network: Regtest` and - // the recovery loop stalls. + // * Regtest unconditionally — the Rust side has no built-in DAPI + // defaults for it, so we must supply addresses every time + // (otherwise SDK creation panics with `DAPI addresses not + // available for network: Regtest`, which would stall orphan- + // mnemonic recovery if it ran from a non-regtest active state). + // * Devnet unconditionally — same reason; additionally needs an + // explicit `quorum_url` because the default quorum endpoint + // `https://quorums.devnet..networks.dash.org` is template- + // interpolated from a devnet name we don't carry across FFI. + // * Mainnet/testnet only when the user opted in via + // `useDockerSetup` (existing dashmate-on-localhost flow). When + // 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. let result: DashSDKResult - let forceLocal = network == .regtest + let useOverrideAddresses = network == .regtest + || network == .devnet || UserDefaults.standard.bool(forKey: "useDockerSetup") - if forceLocal { - let localAddresses = Self.platformDAPIAddresses - result = localAddresses.withCString { addressesCStr -> DashSDKResult in - var mutableConfig = config - mutableConfig.dapi_addresses = addressesCStr - return dash_sdk_create_trusted(&mutableConfig) + let overrideQuorumURL: String? = Self.platformQuorumURL + + // Resolve the DAPI address list. Two paths: + // + // * Devnet → ALWAYS auto-discover from `{quorumURL}/masternodes` + // fresh on every SDK build. The user input surface for devnet + // is just the quorum URL — DAPI nodes are an implementation + // detail of which masternodes happen to be ENABLED right now. + // Doing this every init is what makes the path self-healing + // when a node goes down on the chain. Cheap: one HTTP round- + // trip (~200ms) at network-switch cadence, which the user + // pays for explicitly anyway. + // + // * Regtest / `useDockerSetup` → respect the existing + // `platformDAPIAddresses` UserDefaults override (default + // `http://127.0.0.1:2443`). This is the dashmate-local flow + // that's been stable; it has no /masternodes service to + // consult. + // + // * Mainnet/testnet without overrides → Rust side picks seeds. + let overrideAddresses: String? + if network == .devnet { + if let quorum = overrideQuorumURL, + let discovered = Self.discoverDAPIAddresses(quorumBase: quorum) { + overrideAddresses = discovered + } else { + // Quorum URL unset, or /masternodes unreachable / wrong shape. + // Fall through with nil; Rust will refuse to build the SDK + // and the resulting error surfaces in the iOS UI as + // "Disconnected", prompting the user to fix the Quorum URL. + overrideAddresses = nil } + } else if useOverrideAddresses { + overrideAddresses = Self.platformDAPIAddresses } else { - result = dash_sdk_create_trusted(&config) + overrideAddresses = nil + } + + result = SDK.withOptionalCStrings( + overrideAddresses, + overrideQuorumURL + ) { addressesCStr, quorumCStr in + var mutableConfig = config + if let addressesCStr { mutableConfig.dapi_addresses = addressesCStr } + if let quorumCStr { mutableConfig.quorum_url = quorumCStr } + return dash_sdk_create_trusted(&mutableConfig) } // Check for errors @@ -148,6 +333,34 @@ public final class SDK: @unchecked Sendable { self.network = network } + /// Run `body` with two optional C-string pointers. Each input string, + /// when non-nil, is materialized into a NUL-terminated C buffer that is + /// valid for the duration of the call; nil inputs pass through as nil + /// pointers. Mirrors `String.withCString` for the two-optional-string + /// case so the SDK init can hand both `dapi_addresses` and + /// `quorum_url` into a single FFI call without nested withCString + /// closures. + private static func withOptionalCStrings( + _ a: String?, + _ b: String?, + _ body: (UnsafePointer?, UnsafePointer?) -> R + ) -> R { + switch (a, b) { + case (nil, nil): + return body(nil, nil) + case (.some(let sa), nil): + return sa.withCString { body($0, nil) } + case (nil, .some(let sb)): + return sb.withCString { body(nil, $0) } + case (.some(let sa), .some(let sb)): + return sa.withCString { aPtr in + sb.withCString { bPtr in + body(aPtr, bPtr) + } + } + } + } + /// Load known contracts into the trusted context provider /// This avoids network calls for these contracts when they're needed public func loadKnownContracts(_ contracts: [(id: String, data: Data)]) throws { diff --git a/packages/swift-sdk/SwiftExampleApp/Info.plist b/packages/swift-sdk/SwiftExampleApp/Info.plist new file mode 100644 index 00000000000..8bccc098686 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/Info.plist @@ -0,0 +1,68 @@ + + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIApplicationSupportsIndirectInputEvents + + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp.xcodeproj/project.pbxproj b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp.xcodeproj/project.pbxproj index 261c410179f..f9eb115d3e5 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp.xcodeproj/project.pbxproj +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp.xcodeproj/project.pbxproj @@ -432,7 +432,8 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 44RJ69WHFF; ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Info.plist; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift index 77371f56eb7..61dcd0bbda9 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift @@ -622,6 +622,21 @@ var body: some View { if platformState.currentNetwork == .regtest && useDocker { return ["127.0.0.1:20301"] } + // Devnet: auto-discover SPV peers from the quorum-list + // service's `/masternodes` endpoint. Each masternode reports + // its own `address` field (`ip:CoreP2PPort`) — use the + // verbatim values rather than guessing the canonical 29999 + // port (paloma reports 20001 per masternode, for example). + // No manual SPV input on devnet — the quorum URL is the + // single source of truth (see `OptionsView`'s devnet branch). + if platformState.currentNetwork == .devnet { + guard + let quorum = UserDefaults.standard.string(forKey: "platformQuorumURL"), + !quorum.isEmpty, + let active = SDK.discoverActiveMasternodes(quorumBase: quorum) + else { return [] } + return active.map(\.spvPeer) + } let useLocalCore = UserDefaults.standard.bool(forKey: "useLocalhostCore") guard useLocalCore else { return [] } let raw = UserDefaults.standard.string(forKey: "localCorePeers") ?? "" diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift index cd13c72926e..3a3b7e09b28 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/OptionsView.swift @@ -74,6 +74,13 @@ struct OptionsView: View { @AppStorage("useLocalhostCore") private var customSpvPeersEnabled: Bool = false @AppStorage("localCorePeers") private var customSpvPeers: String = "" + // Devnet endpoint override — Quorum URL only. DAPI nodes are + // auto-discovered from `{quorumURL}/masternodes` at SDK build + // time (see `SDK.discoverDAPIAddresses`); no manual DAPI input. + // Read by `SDK.init` on every network switch / launch; editing + // here redirects the next SDK construction. + @AppStorage("platformQuorumURL") private var devnetQuorumURL: String = "" + /// Default localhost peer string for a given network. Used to /// pre-populate the peers text field when the user enables the /// custom-SPV toggle. The FFI drops bare-IP entries (no port), @@ -109,6 +116,12 @@ struct OptionsView: View { appState.useDockerSetup = false } + // Devnet's SPV peers come from + // `{platformQuorumURL}/masternodes` + // — no UserDefaults state to seed + // here. See `CoreContentView.spvPeerOverride` + // for the devnet branch. + // Update platform state (which will trigger SDK switch) appState.currentNetwork = newNetwork @@ -198,6 +211,34 @@ struct OptionsView: View { } } } + } else if appState.currentNetwork == .devnet { + // Devnet UX: a single user input — the quorum + // list service URL. SPV peers and DAPI nodes + // are both derived from `{quorumURL}/masternodes` + // at SDK build / SPV start time. Each + // masternode entry carries the ip + Core P2P + // port (SPV) and platformHTTPPort (DAPI), so + // we never have to guess. Self-healing on + // node churn — the list re-fetches on every + // network switch / launch. + VStack(alignment: .leading, spacing: 6) { + Text("Quorum URL") + .font(.caption) + .foregroundColor(.secondary) + TextField( + "http://:8080 (quorum-list-server)", + text: $devnetQuorumURL + ) + .font(.system(.body, design: .monospaced)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + + Text("SPV Peers + DAPI nodes are auto-discovered from {Quorum URL}/masternodes. Changes apply on the next SDK build (switch network or relaunch).") + .font(.caption2) + .foregroundColor(.secondary) + } + .padding(.top, 4) } else { Toggle("Use Custom SPV Peers", isOn: $customSpvPeersEnabled) .onChange(of: customSpvPeersEnabled) { _, isOn in diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift index b615bed75ea..c78259ce3ea 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift @@ -56,6 +56,16 @@ final class WalletManagerStore: ObservableObject { /// of each network; lookup is O(1). private var managers: [Network: PlatformWalletManager] = [:] + /// SDK handle pointer the cached manager was configured against. + /// Used in `activate()` to detect a stale cache when `AppState` + /// rebuilds the SDK (network switch / `platformDAPIAddresses` or + /// `platformQuorumURL` change in Options). On mismatch we tear + /// down the cached manager and rebuild against the fresh SDK — + /// otherwise the cached manager keeps using its own Sdk clone + /// with stale DAPI / quorum endpoints, and proof verification + /// fails forever ("no available addresses to use"). + private var managerSdkHandles: [Network: UnsafeMutablePointer] = [:] + /// SwiftData container shared across every manager. Each /// manager's persistence handler narrows its `loadWalletList` /// fetch to its own network so the shared store doesn't cause @@ -85,11 +95,35 @@ final class WalletManagerStore: ObservableObject { /// network) that need a manager for a non-active network without /// triggering a user-visible network switch. func activate(network: Network, sdk: SDK, makeActive: Bool = true) throws { + // Stale-cache check: the cached manager's Sdk clone is locked + // in at `configure` time (the FFI is single-shot — see + // `PlatformWalletManager.configure`'s `precondition(!isConfigured)`). + // If `AppState` has rebuilt the SDK since (network switch or + // UserDefaults endpoint override change), the cached manager + // is still pointing at the old endpoints and would keep + // failing proof verification. Drop it so the rebuild below + // picks up the fresh SDK. if let existing = managers[network] { - if makeActive && existing !== activeManager { - activeManager = existing + let cachedHandle = managerSdkHandles[network] + if cachedHandle == sdk.handle { + if makeActive && existing !== activeManager { + activeManager = existing + } + return } - return + SDKLogger.log( + "WalletManagerStore: SDK changed for \(network.displayName); " + + "rebuilding cached manager", + minimumLevel: .medium + ) + // No `activeManager = nil` — the field isn't optional. The + // rebuild below will overwrite it via `if makeActive { + // activeManager = manager }`. Until that line runs, the + // old `activeManager` reference still points at the + // now-stale cached manager, but it'll be replaced before + // any caller observes it (this method is synchronous). + managers[network] = nil + managerSdkHandles[network] = nil } let manager = PlatformWalletManager() try manager.configure(sdk: sdk, modelContainer: modelContainer) @@ -108,6 +142,7 @@ final class WalletManagerStore: ObservableObject { ) } managers[network] = manager + managerSdkHandles[network] = sdk.handle if makeActive { activeManager = manager }