From f23f0ade1cd2a5cc55bc3ea94a1035aaed85053d Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Wed, 27 May 2026 23:36:55 +0700 Subject: [PATCH 1/4] feat(swift-sdk,sdk-ffi): wire devnet SDK config + auto-discover masternodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the devnet ergonomics fixes that were riding along with the 1M-note shielded-sync work. Split out so they can land independently of the chain-side snapshot bake and the iOS sync-visibility UI changes. Changes: - `DashSDKConfig` gains a `quorum_url` field (rs-sdk-ffi). Plumbed into `dash_sdk_create_trusted` → `TrustedHttpContextProvider::new_with_url` so devnet builds can point at the quorum-list service. - `SDK.swift` adds `discoverActiveMasternodes(quorumBase:)` — fetches `{base}/masternodes` and returns `[(spvPeer, dapiUrl)]`. Used at SDK build time to populate DAPI addresses without manual entry. - `SDK.init` for devnet now always auto-discovers fresh from `/masternodes`; no UserDefaults write-back so node churn self-heals on every relaunch / network switch. - `OptionsView` devnet branch reduced from three inputs (SPV peers / DAPI URL / Quorum URL) to one (Quorum URL only) — both SPV and DAPI derive from the same `/masternodes` response. - `CoreContentView.spvPeerOverride` devnet branch maps `discoverActiveMasternodes` output to its `spvPeer` field (paloma reports `ip:20001` per node; we use the verbatim values rather than guessing 29999). - `WalletManagerStore.activate` adds a stale-SDK cache check — the cached `PlatformWalletManager` is rebuilt when `AppState` swaps the SDK (network switch or endpoint override change) so proof verification doesn't fail forever against stale DAPI endpoints. - `SwiftExampleApp/Info.plist` is now hand-managed (peer to .xcodeproj so `PBXFileSystemSynchronizedRootGroup` doesn't auto-include it as a bundle resource). Adds `NSAllowsArbitraryLoads=true` so the HTTP `/masternodes` fetch isn't blocked by ATS. Test-only banner in the plist makes the dev intent explicit. - `SDKLogger.log` now mirrors to `NSLog` in addition to `Swift.print` so `simctl spawn booted log stream` captures it without Xcode attached. NOT in this PR (separate follow-ups): - Per-pass timing UI rows / longest-pass tracking / per-chunk progress callback (sync visibility bucket). - Genesis-bake of 1M Orchard notes on the chain side (`rs-drive-abci`, `dashmate`, `rs-platform-wallet` test scaffolding). --- packages/rs-sdk-ffi/src/sdk.rs | 51 +++- packages/rs-sdk-ffi/src/token/claim.rs | 1 + .../rs-sdk-ffi/src/token/emergency_action.rs | 1 + packages/rs-sdk-ffi/src/token/freeze.rs | 1 + packages/rs-sdk-ffi/src/types.rs | 9 + .../Core/Services/SDKLogger.swift | 6 + .../swift-sdk/Sources/SwiftDashSDK/SDK.swift | 221 ++++++++++++++++-- packages/swift-sdk/SwiftExampleApp/Info.plist | 68 ++++++ .../SwiftExampleApp.xcodeproj/project.pbxproj | 6 +- .../Core/Views/CoreContentView.swift | 15 ++ .../SwiftExampleApp/Views/OptionsView.swift | 41 ++++ .../SwiftExampleApp/WalletManagerStore.swift | 41 +++- 12 files changed, 432 insertions(+), 29 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/Info.plist 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..6e17d826aa8 100644 --- a/packages/rs-sdk-ffi/src/types.rs +++ b/packages/rs-sdk-ffi/src/types.rs @@ -77,6 +77,15 @@ 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). + /// + /// 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/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..03f4694ad68 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -89,6 +89,114 @@ 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"`. + /// + /// `public` because both the SDK init (DAPI fan-out) and the + /// SwiftExampleApp's SPV start path call this against the same + /// endpoint — keeping it in one place means a single round-trip + /// per build instead of two, but callers must handle their own + /// caching if needed. + 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 + let platformHTTPPort: UInt16 + } + + guard + let env = try? JSONDecoder().decode(Envelope.self, from: data), + env.success + else { return nil } + + let active: [(String, String)] = env.data.compactMap { mn in + guard mn.status == "ENABLED" else { return nil } + let host = mn.address.split(separator: ":").first.map(String.init) ?? mn.address + return (mn.address, "https://\(host):\(mn.platformHTTPPort)") + } + 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 +206,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 +305,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..a0bf7aa2934 --- /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..93529314cc1 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; @@ -460,7 +461,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 } From f863729899bc037983b728d6dfa6be99db7a8444 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 28 May 2026 08:31:21 +0700 Subject: [PATCH 2/4] fix(rs-sdk-ffi): add quorum_url to context_provider_test DashSDKConfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI macOS Rust test job failed compilation — the integration test under `packages/rs-sdk-ffi/tests/context_provider_test.rs` builds a DashSDKConfig struct literal that was missed when the new field was added. Production and unit-test sites were already updated. --- packages/rs-sdk-ffi/tests/context_provider_test.rs | 1 + 1 file changed, 1 insertion(+) 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, }; From 7cfede7b6b330cf10de5b357e0c478092d0b1013 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 28 May 2026 09:05:57 +0700 Subject: [PATCH 3/4] fix(swift-sdk): address in-scope PR review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Triaged review feedback on PR #3755. Applied the five findings that were in scope for "devnet wiring + auto-discover masternodes": 1. `SDK.swift::discoverActiveMasternodes` — align active-node filter with the Rust trusted-context provider. Make `platformHTTPPort` optional (default 443 when missing — matches Rust's per-network fallback) so a single misbehaving JSON entry no longer fails the whole decode. Add `version_check == "success"` to the filter alongside `status == "ENABLED"` so nodes the quorum service has flagged as incompatible don't get seeded into the DAPI fan-out or SPV peer list. 2. `project.pbxproj` Release config — flip back to `GENERATE_INFOPLIST_FILE = YES`. The hand-managed `Info.plist` with `NSAllowsArbitraryLoads = true` is a Debug-only dev tool; Release builds now use the auto-generated plist with default ATS (preventing a release archive from accidentally shipping with ATS off if anyone runs `xcodebuild -configuration Release`). Debug still uses the permissive plist for `/masternodes` HTTP fetches. 3. `SDK.swift::discoverActiveMasternodes` doc — fix incorrect claim that the method's `public` visibility saves a round-trip. The two known call sites (`SDK.init` via `discoverDAPIAddresses`, and `CoreContentView.spvPeerOverride`) each call independently with no shared cache, so an SDK rebuild on devnet performs two `/masternodes` fetches. Clarified in the doc. 4. `DashSDKConfig::quorum_url` doc — document that the field is only honored on the `dash_sdk_create_trusted` path (which builds a `TrustedHttpContextProvider`); the callback-based path uses `CallbackContextProvider` and silently drops non-null values. `dash_sdk_create_with_callbacks` still forwards the value into the extended config since the struct field is required, but readers now know it's a dead field on that path. 5. `Info.plist` — replace the leaked `/Users/ivanshumkov/Projects/dashpay/quorum-list-server` absolute path in the test-only banner comment with the public GitHub URL. Verified `cargo check -p rs-sdk-ffi --tests` clean. Verified `xcodebuild` succeeds on both Debug and Release configurations (iPhone 17 simulator, arm64) — Release with the new auto-generated plist builds without errors. Out-of-scope findings deliberately not addressed in this PR: - `SDK.swift:130` cleartext-URL guard: enforcing https-or-loopback would actively break the documented use case (paloma devnet at `http://44.238.203.84:8080` — not a loopback). - `SDK.swift:156` main-thread blocking: fixing properly requires making `SDK.init` async, an API-breaking refactor that spans multiple call sites (`AppState.initializeSDK`, `switchNetwork`, `spvPeerOverride`). Better as its own focused PR. --- packages/rs-sdk-ffi/src/types.rs | 7 +++ .../swift-sdk/Sources/SwiftDashSDK/SDK.swift | 44 +++++++++++++++---- packages/swift-sdk/SwiftExampleApp/Info.plist | 2 +- .../SwiftExampleApp.xcodeproj/project.pbxproj | 3 +- 4 files changed, 45 insertions(+), 11 deletions(-) diff --git a/packages/rs-sdk-ffi/src/types.rs b/packages/rs-sdk-ffi/src/types.rs index 6e17d826aa8..efdaa4e45be 100644 --- a/packages/rs-sdk-ffi/src/types.rs +++ b/packages/rs-sdk-ffi/src/types.rs @@ -83,6 +83,13 @@ pub struct DashSDKConfig { /// 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, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index 03f4694ad68..491d29d398e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -108,13 +108,21 @@ public final class SDK: @unchecked Sendable { /// takes the verbatim `address` field (`:`). /// /// Returns nil on any failure (timeout, JSON shape mismatch, etc.). - /// Filters to `status == "ENABLED"`. + /// 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 this against the same - /// endpoint — keeping it in one place means a single round-trip - /// per build instead of two, but callers must handle their own - /// caching if needed. + /// 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)]? { @@ -163,7 +171,23 @@ public final class SDK: @unchecked Sendable { struct Masternode: Decodable { let address: String // "ip:CoreP2PPort" let status: String - let platformHTTPPort: UInt16 + // Optional to match the Rust trusted-context provider, which + // tolerates entries missing `platform_http_port` 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. + let platformHTTPPort: UInt16? + // Same `version_check` 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? + + enum CodingKeys: String, CodingKey { + case address + case status + case platformHTTPPort + case versionCheck = "version_check" + } } guard @@ -171,10 +195,14 @@ public final class SDK: @unchecked Sendable { 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" else { return nil } + guard mn.status == "ENABLED", mn.versionCheck == "success" else { return nil } let host = mn.address.split(separator: ":").first.map(String.init) ?? mn.address - return (mn.address, "https://\(host):\(mn.platformHTTPPort)") + let dapiPort = mn.platformHTTPPort ?? defaultDapiPort + return (mn.address, "https://\(host):\(dapiPort)") } return active.isEmpty ? nil : active } diff --git a/packages/swift-sdk/SwiftExampleApp/Info.plist b/packages/swift-sdk/SwiftExampleApp/Info.plist index a0bf7aa2934..8bccc098686 100644 --- a/packages/swift-sdk/SwiftExampleApp/Info.plist +++ b/packages/swift-sdk/SwiftExampleApp/Info.plist @@ -53,7 +53,7 @@ endpoints. Required so `SDK.discoverActiveMasternodes` can hit `http://:8080/masternodes` (the dash quorum-list-server runs HTTP by default — see - `/Users/ivanshumkov/Projects/dashpay/quorum-list-server`). + https://github.com/dashpay/quorum-list-server). On a production build this MUST be removed and the quorum-list-server fronted by HTTPS. Tracked alongside the rest diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp.xcodeproj/project.pbxproj b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp.xcodeproj/project.pbxproj index 93529314cc1..f9eb115d3e5 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp.xcodeproj/project.pbxproj +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp.xcodeproj/project.pbxproj @@ -461,8 +461,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 44RJ69WHFF; ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = NO; - INFOPLIST_FILE = Info.plist; + GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; From a6a9c5a9df9e7aefb30ba5fb8919d139628f0494 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 28 May 2026 10:09:18 +0700 Subject: [PATCH 4/4] fix(swift-sdk): drop wrong CodingKeys map on Masternode decoder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit My earlier fix added `case versionCheck = "version_check"` to `Masternode.CodingKeys`, telling JSONDecoder to look for snake_case `version_check` in the `/masternodes` response. The actual JSON wire key is camelCase `versionCheck` — the Rust source of truth at `packages/rs-sdk-trusted-context-provider/src/provider.rs:72-80` renames its snake_case Rust field with `#[serde(rename = "versionCheck")]`, so the server emits camelCase on the wire. Consequence: every decoded entry had `versionCheck == nil`, the filter `mn.versionCheck == "success"` was false for every node, `active` was always empty, `discoverActiveMasternodes` returned nil, and `SDK.init(.devnet)` fell through to `overrideAddresses = nil` — breaking every cold devnet boot. Fix: drop the `CodingKeys` enum entirely. Swift's default `Decodable` synthesis matches property names to JSON keys literally, so `platformHTTPPort` and `versionCheck` properties match the camelCase wire keys with no rename machinery. Added a doc comment recording the wire-format invariant so it stays in sync with the Rust source if either side renames. --- .../swift-sdk/Sources/SwiftDashSDK/SDK.swift | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift index 491d29d398e..f10331e22c8 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift @@ -172,22 +172,22 @@ public final class SDK: @unchecked Sendable { let address: String // "ip:CoreP2PPort" let status: String // Optional to match the Rust trusted-context provider, which - // tolerates entries missing `platform_http_port` and substitutes + // 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 `version_check` field the Rust provider filters on. + // 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? - - enum CodingKeys: String, CodingKey { - case address - case status - case platformHTTPPort - case versionCheck = "version_check" - } } guard