Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -185,14 +185,14 @@ class SPVClient: @unchecked Sendable {
func destroy() {
dash_spv_ffi_client_destroy(client)
dash_spv_ffi_config_destroy(config)

client = nil
config = nil
}

// MARK: - Synchronization

func startSync() async throws {
func startSync() throws {
let result = dash_spv_ffi_client_run(
client
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,9 @@ func print(_ items: Any..., separator: String = " ", terminator: String = "\n")
Swift.print(output, terminator: terminator)
}

// DESIGN NOTE: This class feels like something that should be in the example app,
// DESIGN NOTE: This class feels like something that should be in the example app,
// we, as sdk developers, provide the tools and ffi wrappers, but how to
// use them depends on the sdk user, for example, by implementing the SPV event
// use them depends on the sdk user, for example, by implementing the SPV event
// handlers, the user can decide what to do with the events, but if we implement them in the sdk
// we are taking that decision for them, and maybe not all users want the same thing
@MainActor
Expand All @@ -96,10 +96,10 @@ public class WalletService: ObservableObject {
@Published public var masternodesEnabled = true
@Published public var lastSyncError: Error?
@Published var network: AppNetwork

// Internal properties
private var modelContainer: ModelContainer

// SPV Client and Wallet wrappers
private var spvClient: SPVClient
public private(set) var walletManager: CoreWalletManager
Expand All @@ -112,11 +112,6 @@ public class WalletService: ObservableObject {

let dataDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.appendingPathComponent("SPV").appendingPathComponent(network.rawValue).path

// Ensure the data directory exists before initializing the SPV client
if let dataDir = dataDir {
try? FileManager.default.createDirectory(atPath: dataDir, withIntermediateDirectories: true)
}

// Create SPV client first with dummy handlers to obtain the wallet manager,
// then destroy and recreate with real handlers that reference self.
let dummyClient = try! SPVClient(
Expand Down Expand Up @@ -152,22 +147,17 @@ public class WalletService: ObservableObject {
// Recreate the wallet manager with the new client
self.walletManager = try! CoreWalletManager(spvClient: self.spvClient, modelContainer: modelContainer)
}

deinit {
spvClient.stopSync()
spvClient.destroy()
}

private func initializeNewSPVClient() {
SDKLogger.log("Initializing SPV Client for \(self.self.network.rawValue)...", minimumLevel: .medium)

let dataDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first?.appendingPathComponent("SPV").appendingPathComponent(self.network.rawValue).path

// Ensure the data directory exists before initializing the SPV client
if let dataDir = dataDir {
try? FileManager.default.createDirectory(atPath: dataDir, withIntermediateDirectories: true)
}

// This ensures no memory leaks when creating a new client
// and unlocks the storage in case we are about to use the same (we probably are)
self.spvClient.destroy()
Expand Down Expand Up @@ -200,7 +190,7 @@ public class WalletService: ObservableObject {

SDKLogger.log("WalletManager wrapper initialized successfully", minimumLevel: .medium)
}

// MARK: - Trusted Mode / Masternode Sync
public func setMasternodesEnabled(_ enabled: Bool) {
masternodesEnabled = enabled
Expand All @@ -217,11 +207,11 @@ public class WalletService: ObservableObject {

// MARK: - Sync Management

public func startSync() async {
public func startSync() {
lastSyncError = nil

do {
try await spvClient.startSync()
try spvClient.startSync()
} catch {
self.lastSyncError = error
print("❌ Sync failed: \(error)")
Expand All @@ -232,7 +222,7 @@ public class WalletService: ObservableObject {
// pausing and resuming is not supported so, the trick is the following,
// stop the old client and create a new one in its initial state xd
spvClient.stopSync()

self.initializeNewSPVClient()
}

Expand Down Expand Up @@ -265,17 +255,17 @@ public class WalletService: ObservableObject {

public func switchNetwork(to network: AppNetwork) async {
guard network != self.network else { return }

print("=== WalletService.switchNetwork START ===")
print("Switching from \(self.network.rawValue) to \(network.rawValue)")

self.network = network

self.stopSync()

print("=== WalletService.switchNetwork END ===")
}

// MARK: - SPV Event Handlers implementations

internal final class SPVProgressUpdateEventHandlerImpl: SPVProgressUpdateEventHandler, Sendable {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -325,9 +325,7 @@ var body: some View {
}

private func startSync() {
Task {
await walletService.startSync()
}
walletService.startSync()
}
Comment on lines 327 to 329
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== WalletService isolation and startSync body =="
fd -a 'WalletService.swift' | while read -r f; do
  echo "FILE: $f"
  rg -n -C3 '@MainActor|public func startSync\(|spvClient.startSync\(' "$f"
done

echo
echo "== SPVClient startSync and FFI run call =="
fd -a 'SPVClient.swift' | while read -r f; do
  echo "FILE: $f"
  rg -n -C3 'func startSync\(|dash_spv_ffi_client_run' "$f"
done

echo
echo "== Any explicit background offloading around sync start =="
rg -n --type=swift -C2 'Task\.detached|DispatchQueue\.global|withCheckedContinuation|startSync\(' packages/swift-sdk

Repository: dashpay/platform

Length of output: 45226


UI-thread blocking risk: startSync() invokes FFI without background offloading.

Line 328 calls walletService.startSync() directly from the main-thread action path. WalletService.startSync() is @MainActor and invokes spvClient.startSync(), which directly calls the synchronous FFI dash_spv_ffi_client_run() without dispatching to a background queue. This breaks the established SDK pattern: similar heavy FFI operations (e.g., in ShieldedPoolClient, AddressSyncService, ShieldedCryptoService) consistently offload work via DispatchQueue.global().async to prevent main-thread blocking. SPV synchronization is a blocking I/O operation and must be moved off the main thread.

Wrap the FFI call in DispatchQueue.global().async within SPVClient.startSync(), or refactor WalletService.startSync() to dispatch the call asynchronously:

public func startSync() {
    lastSyncError = nil
    DispatchQueue.global().async {
        do {
            try self.spvClient.startSync()
        } catch {
            Task { `@MainActor` in
                self.lastSyncError = error
                print("❌ Sync failed: \($0)")
            }
        }
    }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CoreContentView.swift`
around lines 327 - 329, The call path currently invokes
WalletService.startSync() (which is `@MainActor`) and ultimately
SPVClient.startSync() that calls the synchronous FFI dash_spv_ffi_client_run(),
blocking the main thread; fix by offloading the blocking FFI call to a
background queue—either wrap the spvClient.startSync() invocation inside
DispatchQueue.global().async inside WalletService.startSync() (clearing
lastSyncError before dispatch and setting lastSyncError back on the MainActor on
error), or move the DispatchQueue.global().async into SPVClient.startSync()
around the dash_spv_ffi_client_run() call so that the synchronous FFI never runs
on the main thread. Ensure errors are propagated back to the main actor (e.g.,
update lastSyncError on MainActor) and keep caller API unchanged.


private func pauseSync() {
Expand Down
Loading