diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 55da319da58..403f72d5217 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -149,6 +149,58 @@ struct PlatformWalletNativeTeardownCalls: @unchecked Sendable { ) } +/// Arguments of one native create-wallet call, captured on the main actor +/// before hopping to the destroy queue. Every field is a plain value type. +struct PlatformWalletCreateParams: Sendable { + let mnemonic: String + let network: Network + let accountOptions: UInt32 + let birthHeight: UInt32? +} + +/// Native entry point used by the off-main create orchestration in +/// [`PlatformWalletManager.performCreateWallet`]. Same design as +/// [`PlatformWalletNativeTeardownCalls`]: injecting the function (rather +/// than the outcome) keeps the production timing, result mapping, and +/// logging under test without calling Rust. The unchecked conformance is +/// intentional: the closure is an immutable C-entry wrapper, and the whole +/// value is copied onto the dedicated queue. +/// +/// Tests with a fake manager handle must inject this table: handles come from +/// a process-global registry, so an arbitrary non-zero test value is not +/// guaranteed to miss a live Rust entry owned by another test. +struct PlatformWalletNativeCreateCalls: @unchecked Sendable { + /// Mirrors `platform_wallet_manager_create_wallet_from_mnemonic_with_birth_height`, + /// folding the two out-params into the return value (the 32-byte wallet + /// id already copied into a `Data`). + typealias Call = @Sendable (Handle, PlatformWalletCreateParams) + -> (result: PlatformWalletFFIResult, walletHandle: Handle, walletId: Data) + + let createFromMnemonic: Call + + static let live = PlatformWalletNativeCreateCalls( + createFromMnemonic: { managerHandle, params in + var walletHandle: Handle = NULL_HANDLE + var walletId: FFIByteTuple32 = + (0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0) + let result = params.mnemonic.withCString { mnemonicPtr in + platform_wallet_manager_create_wallet_from_mnemonic_with_birth_height( + managerHandle, + mnemonicPtr, + params.network.ffiValue, + params.accountOptions, + params.birthHeight != nil, + params.birthHeight ?? 0, + &walletHandle, + &walletId + ) + } + let idData = withUnsafeBytes(of: &walletId) { Data($0) } + return (result, walletHandle, idData) + } + ) +} + /// The one thing SwiftUI needs for all wallet operations. /// /// Owns the Rust-side `PlatformWalletManager` handle which drives: @@ -351,17 +403,50 @@ public class PlatformWalletManager: ObservableObject { /// an uncached no-op because the manager can still be configured later. private var shutdownTask: Task? + /// Set the moment [`shutdown()`] decides to proceed, BEFORE it drains + /// in-flight creates: closes admission for every `createWallet` overload + /// so the drain below can terminate without a synchronous create entering + /// while the MainActor is reentrant at an `await`. + private var shutdownRequested = false + + /// Async `createWallet` calls between admission and the end of their + /// MainActor epilogue. [`shutdown()`] waits for this to reach zero + /// before taking the handle: an admitted create must complete its FULL + /// transaction (FFI + publish) or fail on its own terms — never be + /// failed retroactively by a concurrent teardown after the native + /// create already persisted wallet data (the caller would roll back its + /// mnemonic and orphan the persisted rows). + private var activeCreateCount = 0 + private var createDrainContinuations: [CheckedContinuation] = [] + /// Test seam for the individual native calls. Production keeps `.live`; /// tests replace the function table while still running the production /// teardown orchestration end-to-end. internal var nativeTeardownCalls = PlatformWalletNativeTeardownCalls.live - /// Dedicated serial queue for the blocking native teardown. The Rust - /// `destroy` runs `block_on(shutdown())` on the calling thread and can - /// legitimately take tens of seconds when an in-flight sync pass ignores - /// cancellation, so it must park a plain GCD thread — never the main - /// thread, and never a Swift Concurrency cooperative-pool thread (which - /// is why this is a DispatchQueue and not `Task.detached`). + /// Test seam for the native create call used by the async + /// `createWallet(mnemonic:)` overload; same contract as + /// [`nativeTeardownCalls`]. + internal var nativeCreateCalls = PlatformWalletNativeCreateCalls.live + + /// Dedicated serial queue for the blocking native teardown AND the + /// blocking native create (async `createWallet(mnemonic:)` overload). + /// Both park the calling thread — the Rust `destroy` runs + /// `block_on(shutdown())` and can legitimately take tens of seconds when + /// an in-flight sync pass ignores cancellation; create derives hundreds + /// of keys and flushes persistence synchronously — so they must park a + /// plain GCD thread: never the main thread, and never a Swift + /// Concurrency cooperative-pool thread (which is why this is a + /// DispatchQueue and not `Task.detached`). + /// + /// Sharing ONE queue is deliberate: memory safety already comes from the + /// Rust-side registry (the create call holds a read guard for its whole + /// duration; destroy's map removal takes the write lock), so the queue's + /// job is deterministic FIFO — a teardown enqueued after an admitted + /// create runs after it, never concurrently with it. Accepted trade-off: + /// the queue is process-wide, so a create on one manager can queue + /// behind another manager's slow destroy (rare — hosts await `shutdown()` + /// between lifecycle operations). nonisolated static let destroyQueue = DispatchQueue( label: "org.dash.platform-wallet.destroy", qos: .userInitiated @@ -437,17 +522,33 @@ public class PlatformWalletManager: ObservableObject { /// for the host to log. @discardableResult public func shutdown() async -> PlatformWalletShutdownMetrics { - if let task = shutdownTask { - return await task.value - } - guard handle != NULL_HANDLE else { - // Never configured (or a test double without a handle): nothing - // to tear down. Do not cache this no-op: a manager may still be - // configured later, and that live handle must then be torn down. - return PlatformWalletShutdownMetrics( - steps: [], - totalMilliseconds: 0, - ranOffMainThread: false) + // Drain loop: close admission for new async creates, then wait for + // every already-admitted create to finish its FULL transaction + // (native create + MainActor epilogue). Draining before take-once + // means a create whose FFI already persisted wallet data can never + // be failed retroactively by this teardown — the caller would roll + // back its mnemonic and orphan the persisted rows. Each await can + // interleave with other MainActor work, so every idempotency / + // no-op condition is re-checked after resuming. + while true { + if let task = shutdownTask { + return await task.value + } + guard handle != NULL_HANDLE else { + // Never configured (or a test double without a handle): + // nothing to tear down. Do not cache this no-op: a manager + // may still be configured later, and that live handle must + // then be torn down. + return PlatformWalletShutdownMetrics( + steps: [], + totalMilliseconds: 0, + ranOffMainThread: false) + } + shutdownRequested = true + if activeCreateCount == 0 { break } + await withCheckedContinuation { continuation in + createDrainContinuations.append(continuation) + } } // Take-once: from this point every FFI entry gated on @@ -703,6 +804,17 @@ public class PlatformWalletManager: ObservableObject { // MARK: - Wallet creation + /// Reject wallet creation once shutdown has closed admission, including + /// during the drain window where the manager's handle is intentionally + /// still live for an already-admitted async create. + private func ensureWalletCreationAllowed() throws { + try ensureConfigured() + guard !shutdownRequested else { + throw PlatformWalletError.invalidHandle( + "manager shutdown is in progress; wallet creation rejected") + } + } + /// Create a wallet from a BIP39 mnemonic phrase (English). /// /// Stores the returned wallet as the active [`wallet`] published @@ -725,7 +837,7 @@ public class PlatformWalletManager: ObservableObject { createDefaultAccounts: Bool = true, birthHeight: UInt32? = nil ) throws -> ManagedPlatformWallet { - try ensureConfigured() + try ensureWalletCreationAllowed() var walletHandle: Handle = NULL_HANDLE var walletId: FFIByteTuple32 = (0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0) @@ -754,6 +866,114 @@ public class PlatformWalletManager: ObservableObject { return w } + /// Off-main variant of [`createWallet(mnemonic:...)`]: identical + /// semantics (same FFI call, `birthHeight` contract, name persistence, + /// and published-state update), but the blocking native create — key + /// derivation for every account plus a synchronous persistence flush, + /// seconds of work — runs on [`destroyQueue`] instead of the main + /// thread. In an `async` context overload resolution prefers this + /// variant; sync contexts keep the sync one. + /// + /// Throws `PlatformWalletError.invalidHandle` when the manager is shut + /// down or a shutdown is already in progress — always BEFORE any native + /// work: an admitted create is guaranteed to run its full transaction + /// (native create + publish); [`shutdown()`] drains admitted creates + /// before taking the handle, so a create whose FFI persisted wallet + /// data can never be failed retroactively by a concurrent teardown. + @discardableResult + public func createWallet( + mnemonic: String, + network: Network, + name: String? = nil, + createDefaultAccounts: Bool = true, + birthHeight: UInt32? = nil + ) async throws -> ManagedPlatformWallet { + // Admission is checked on the MainActor with no suspension before + // the count increment, so `shutdown()`'s drain can never miss an + // admitted create. + try ensureWalletCreationAllowed() + activeCreateCount += 1 + defer { + activeCreateCount -= 1 + if activeCreateCount == 0, !createDrainContinuations.isEmpty { + let waiters = createDrainContinuations + createDrainContinuations.removeAll() + waiters.forEach { $0.resume() } + } + } + + let h = handle + let params = PlatformWalletCreateParams( + mnemonic: mnemonic, + network: network, + accountOptions: createDefaultAccounts ? 1 : 0, + birthHeight: birthHeight) + let calls = nativeCreateCalls + + // Deliberately a direct continuation, NOT the `Task {}` wrapper + // `shutdown()` uses (that wrapper exists only to share + // `shutdownTask`): the dispatch below happens synchronously at this + // suspension point, so an admitted create is enqueued on the shared + // FIFO queue before any later `shutdown()`'s teardown block — + // teardown can never overtake an already-admitted create. A + // `shutdown()` that completed BEFORE this point already failed the + // `ensureConfigured()` above, so nothing was enqueued. + let created: Result = + await withCheckedContinuation { continuation in + Self.destroyQueue.async { + continuation.resume( + returning: Self.performCreateWallet(h, params: params, calls: calls)) + } + } + let w = try created.get() + + // Defense in depth only: `shutdown()` drains admitted creates + // before taking the handle, so this cannot fire from the production + // shutdown path. It guards the invariant that the manager never + // publishes a wallet after its handle was torn down (dropping `w` + // lets its deinit release the wrapper handle — a registry no-op + // after manager teardown). + guard handle != NULL_HANDLE else { + assertionFailure("shutdown took the handle under an admitted create despite the drain") + throw PlatformWalletError.invalidHandle( + "manager was shut down while createWallet ran off-main") + } + if let name, !name.isEmpty { + persistenceHandler?.setWalletName(walletId: w.walletId, name: name) + } + self.wallets[w.walletId] = w + return w + } + + /// The blocking native create body of the async + /// [`createWallet(mnemonic:...)`] overload: runs the injected create + /// call, maps the FFI result to Swift types on the queue (the raw + /// result's Rust-owned message string never crosses the continuation), + /// and logs duration + which thread it ran on — the whole point is + /// `offMain=true`. + nonisolated static func performCreateWallet( + _ handle: Handle, + params: PlatformWalletCreateParams, + calls: PlatformWalletNativeCreateCalls = .live + ) -> Result { + let offMain = !Thread.isMainThread + let start = CFAbsoluteTimeGetCurrent() + let outcome = calls.createFromMnemonic(handle, params) + let result = PlatformWalletResult(outcome.result) + let ms = Int((CFAbsoluteTimeGetCurrent() - start) * 1000) + guard result.isSuccess else { + Self.log.error( + "native create failed in \(ms, privacy: .public)ms offMain=\(offMain, privacy: .public) network=\(params.network.rawValue, privacy: .public): \(String(describing: result.code), privacy: .public): \(result.message ?? "", privacy: .public)" + ) + return .failure(PlatformWalletError(code: result.code, message: result.message)) + } + Self.log.info( + "native create finished in \(ms, privacy: .public)ms offMain=\(offMain, privacy: .public) network=\(params.network.rawValue, privacy: .public)" + ) + return .success( + ManagedPlatformWallet(handle: outcome.walletHandle, walletId: outcome.walletId)) + } + /// Create a wallet from raw 64-byte seed bytes. /// /// See `createWallet(mnemonic:...)` for the `birthHeight` semantics: `nil` @@ -767,7 +987,7 @@ public class PlatformWalletManager: ObservableObject { createDefaultAccounts: Bool = true, birthHeight: UInt32? = nil ) throws -> ManagedPlatformWallet { - try ensureConfigured() + try ensureWalletCreationAllowed() guard seed.count == 64 else { throw PlatformWalletError.invalidParameter( "seed must be 64 bytes, got \(seed.count)" diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift index 7febdf052ed..994f45f7671 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/ContentView.swift @@ -510,7 +510,7 @@ struct ContentView: View { // history), falling back to genesis only for wallets that predate // that metadata — never the current tip, which would skip the // history recovery exists to recover. - let managed = try recoveryManager.createWallet( + let managed = try await recoveryManager.createWallet( mnemonic: mnemonic, network: restoredNetwork, name: restoredName, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift index 8c19ad13e58..3af077393ad 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/CreateWalletView.swift @@ -351,7 +351,12 @@ struct CreateWalletView: View { // metadata must be written under EACH freshly-created // network's id, and `isImported` stamped on each id's // row. - try await MainActor.run { + // An async MainActor closure (not `MainActor.run`, whose + // closure is synchronous): the loop below awaits the + // async `createWallet` overload, so each network's + // blocking native create runs on the SDK's dedicated + // queue instead of freezing the main thread. + let createOnMain: @MainActor () async throws -> Void = { // Per-network results for networks the wallet was // FRESHLY created on this pass — each carries the // scoped `walletId` Rust returned, which is the @@ -384,7 +389,7 @@ struct CreateWalletView: View { for net in selectedNetworks { do { let mgr = try walletManagerStore.backgroundManager(for: net) - let managed = try mgr.createWallet( + let managed = try await mgr.createWallet( mnemonic: mnemonicPhrase, network: net, name: walletLabel, @@ -519,6 +524,7 @@ struct CreateWalletView: View { dismiss() } + try await createOnMain() print("=== WALLET CREATION SUCCESS - networks: \(selectedNetworks.map { $0.displayName }) ===") } catch { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift index cd9affaee51..71d82056c99 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/WalletDetailView.swift @@ -834,11 +834,6 @@ struct WalletInfoView: View { isUpdatingNetworks = true defer { isUpdatingNetworks = false } - // `createWallet` below is a synchronous @MainActor FFI call that - // blocks the main thread, so without yielding first SwiftUI never - // paints the overlay. Let it render one frame before we block. - try? await Task.sleep(nanoseconds: 50_000_000) // ~50ms, one frame - // Add the existing wallet to another network by re-creating it // from the stored mnemonic in that network's manager. The // `walletId` is now network-scoped — the same mnemonic produces @@ -864,7 +859,7 @@ struct WalletInfoView: View { // Enabling an existing wallet on another network: the mnemonic is // pre-existing and may already have on-chain history there — scan // from genesis (birthHeight 0) so prior funds/payments are seen. - let created = try mgr.createWallet( + let created = try await mgr.createWallet( mnemonic: mnemonic, network: network, name: wallet.name ?? wallet.label, diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PlatformWalletCreateWalletTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PlatformWalletCreateWalletTests.swift new file mode 100644 index 00000000000..5e2ea5760e2 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PlatformWalletCreateWalletTests.swift @@ -0,0 +1,331 @@ +import XCTest +import DashSDKFFI +@testable import SwiftDashSDK + +/// Coverage for the async off-main `createWallet(mnemonic:)` overload. +/// +/// Tests inject the native create call (and the teardown table, for the +/// shutdown-interplay cases), so the production `performCreateWallet` +/// orchestration stays under test: off-main execution, result mapping, +/// the shutdown drain (an admitted create completes before teardown takes +/// the handle; new creates are rejected while the drain runs), and the +/// FIFO ordering with teardown on the shared destroy queue — all without +/// invoking a production create or teardown call. +@MainActor +final class PlatformWalletCreateWalletTests: XCTestCase { + + /// Records create invocations and (optionally) blocks inside the native + /// call, so a test can interleave a `shutdown()` with an in-flight + /// off-main create. + private final class CreateRecorder: @unchecked Sendable { + private let lock = NSLock() + private let gate: DispatchSemaphore? + private let failingCode: PlatformWalletFFIResultCode? + private let eventLog: EventLog? + private var invocations: [(handle: Handle, mnemonic: String, ranOnMainThread: Bool)] = [] + private var inFlight = 0 + private var maxInFlightSeen = 0 + + init( + gate: DispatchSemaphore? = nil, + failingCode: PlatformWalletFFIResultCode? = nil, + eventLog: EventLog? = nil + ) { + self.gate = gate + self.failingCode = failingCode + self.eventLog = eventLog + } + + func record( + handle: Handle, + params: PlatformWalletCreateParams + ) -> (result: PlatformWalletFFIResult, walletHandle: Handle, walletId: Data) { + lock.withLock { + invocations.append((handle, params.mnemonic, Thread.isMainThread)) + inFlight += 1 + maxInFlightSeen = max(maxInFlightSeen, inFlight) + } + eventLog?.append("create:begin") + gate?.wait() + let index = lock.withLock { invocations.count } + defer { + eventLog?.append("create:end") + lock.withLock { inFlight -= 1 } + } + if let failingCode { + return ( + PlatformWalletFFIResult(code: failingCode, message: nil), + NULL_HANDLE, + Data() + ) + } + // `ManagedPlatformWallet.deinit` destroys its handle through the + // live FFI. Keep the fake handle at zero so it can never collide + // with a real process-global Rust registry entry; the distinct + // wallet ids are sufficient for the concurrency assertions. + return ( + PlatformWalletFFIResult(code: PLATFORM_WALLET_FFI_RESULT_CODE_SUCCESS, message: nil), + NULL_HANDLE, + Data(repeating: UInt8(index), count: 32) + ) + } + + var count: Int { lock.withLock { invocations.count } } + var handles: [Handle] { lock.withLock { invocations.map(\.handle) } } + var mainThreadFlags: [Bool] { lock.withLock { invocations.map(\.ranOnMainThread) } } + /// Peak number of native creates running at once — 1 proves the + /// shared queue actually serialized concurrent callers. + var maxInFlight: Int { lock.withLock { maxInFlightSeen } } + } + + private nonisolated static func makeCreateCalls( + recorder: CreateRecorder + ) -> PlatformWalletNativeCreateCalls { + PlatformWalletNativeCreateCalls( + createFromMnemonic: { handle, params in + recorder.record(handle: handle, params: params) + } + ) + } + + /// Teardown table whose steps append to a shared event log, so the + /// FIFO ordering of create vs teardown on the shared queue is provable. + private final class EventLog: @unchecked Sendable { + private let lock = NSLock() + private var entries: [String] = [] + func append(_ event: String) { lock.withLock { entries.append(event) } } + var events: [String] { lock.withLock { entries } } + } + + private nonisolated static func makeTeardownCalls(log: EventLog) -> PlatformWalletNativeTeardownCalls { + func step(_ name: String) -> PlatformWalletNativeTeardownCalls.Call { + { _ in + log.append("teardown:\(name)") + return PlatformWalletFFIResult(code: PLATFORM_WALLET_FFI_RESULT_CODE_SUCCESS, message: nil) + } + } + return PlatformWalletNativeTeardownCalls( + spvStop: step("spv_stop"), + platformAddressSyncStop: step("platform_address_sync_stop"), + shieldedSyncStop: step("shielded_sync_stop"), + dashPaySyncStop: step("dashpay_sync_stop"), + dpnsSyncStop: step("dpns_sync_stop"), + destroy: step("destroy") + ) + } + + private func makeManager( + handle: Handle, + createRecorder: CreateRecorder, + teardownLog: EventLog = EventLog() + ) -> PlatformWalletManager { + let manager = PlatformWalletManager.makeForTesting( + handle: handle, + calls: Self.makeTeardownCalls(log: teardownLog) + ) + manager.nativeCreateCalls = Self.makeCreateCalls(recorder: createRecorder) + return manager + } + + // MARK: - Off-main execution + success path + + func testCreateRunsOffMainAndPublishesWallet() async throws { + let recorder = CreateRecorder() + let manager = makeManager(handle: 42, createRecorder: recorder) + + let wallet = try await manager.createWallet(mnemonic: "m", network: .testnet) + + XCTAssertEqual(recorder.count, 1) + XCTAssertEqual(recorder.handles, [42]) + XCTAssertEqual(recorder.mainThreadFlags, [false], "the native create must run off the main thread") + XCTAssertEqual(wallet.walletId, Data(repeating: 1, count: 32)) + XCTAssertTrue(manager.wallets[wallet.walletId] === wallet) + await manager.shutdown() + } + + // MARK: - Error mapping + + func testCreateErrorCodeMapsToTypedError() async { + let recorder = CreateRecorder( + failingCode: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_WALLET_ALREADY_EXISTS) + let manager = makeManager(handle: 7, createRecorder: recorder) + + do { + _ = try await manager.createWallet(mnemonic: "m", network: .testnet) + XCTFail("expected walletAlreadyExists") + } catch let error as PlatformWalletError { + guard case .walletAlreadyExists = error else { + return XCTFail("expected walletAlreadyExists, got \(error)") + } + } catch { + XCTFail("unexpected error: \(error)") + } + XCTAssertTrue(manager.wallets.isEmpty) + await manager.shutdown() + } + + // MARK: - Shutdown interplay + + func testCreateAfterShutdownThrowsWithoutInvokingNativeCall() async { + let recorder = CreateRecorder() + let manager = makeManager(handle: 9, createRecorder: recorder) + + await manager.shutdown() + + do { + _ = try await manager.createWallet(mnemonic: "m", network: .testnet) + XCTFail("expected a throw after shutdown") + } catch is PlatformWalletError { + // expected: ensureConfigured rejects the torn-down manager + } catch { + XCTFail("unexpected error: \(error)") + } + XCTAssertEqual(recorder.count, 0, "the native create must never be reached after shutdown") + } + + /// A shutdown DURING the off-main create window must WAIT: the admitted + /// create completes its full transaction (native create + publish) and + /// only then does the teardown take the handle and run — an admitted + /// create is never failed retroactively after its FFI persisted wallet + /// data (the caller would roll back its mnemonic and orphan the rows). + func testShutdownDuringCreateWindowWaitsForCreateThenTearsDown() async throws { + let gate = DispatchSemaphore(value: 0) + let log = EventLog() + let recorder = CreateRecorder(gate: gate, eventLog: log) + let manager = makeManager(handle: 11, createRecorder: recorder, teardownLog: log) + + let createTask = Task { try await manager.createWallet(mnemonic: "m", network: .testnet) } + + // Wait until the create was admitted and is blocked inside the + // native call on the destroy queue. + while recorder.count == 0 { + try await Task.sleep(for: .milliseconds(5)) + } + + // Start the shutdown; it must drain the in-flight create BEFORE + // taking the handle — so the handle stays live while the gate holds. + let shutdownTask = Task { await manager.shutdown() } + try await Task.sleep(for: .milliseconds(30)) + XCTAssertEqual( + manager.handle, 11, + "shutdown must not take the handle while an admitted create is in flight") + + gate.signal() + let wallet = try await createTask.value + let metrics = await shutdownTask.value + + XCTAssertTrue( + manager.wallets[wallet.walletId] === wallet, + "the drained create must have completed its publish") + XCTAssertEqual(manager.handle, NULL_HANDLE) + XCTAssertEqual(metrics.steps.count, 6) + // The full ordering is in the shared event log: the create ended + // before the first teardown step began. + XCTAssertEqual( + log.events.prefix(2), ["create:begin", "create:end"], + "unexpected event order: \(log.events)") + XCTAssertEqual(log.events.count, 8) + XCTAssertEqual(log.events[2], "teardown:spv_stop") + } + + /// New async and synchronous creates arriving while a shutdown is draining + /// are rejected up front — before any native work. + func testAllCreateOverloadsDuringShutdownDrainAreRejectedBeforeNativeWork() async throws { + let gate = DispatchSemaphore(value: 0) + let recorder = CreateRecorder(gate: gate) + // Use a value outside any realistic monotonic registry range so even a + // regression that reaches the live FFI can only produce a safe miss. + let manager = makeManager(handle: Handle.max, createRecorder: recorder) + + let firstCreate = Task { try await manager.createWallet(mnemonic: "a", network: .testnet) } + while recorder.count == 0 { + try await Task.sleep(for: .milliseconds(5)) + } + let shutdownTask = Task { await manager.shutdown() } + + // Poll through the seed overload with deliberately invalid input. It + // cannot enter FFI before shutdown, and once shutdown closes admission + // the shared creation guard must take precedence over seed validation. + while true { + do { + _ = try manager.createWallet(seed: Data(), network: .testnet) + XCTFail("an empty seed must never create a wallet") + break + } catch PlatformWalletError.invalidParameter { + await Task.yield() + } catch PlatformWalletError.invalidHandle(let message) { + XCTAssertEqual( + message, + "manager shutdown is in progress; wallet creation rejected") + break + } catch { + XCTFail("unexpected error while waiting for shutdown admission to close: \(error)") + break + } + } + + func assertSynchronousShutdownRejection( + _ operation: () throws -> ManagedPlatformWallet, + file: StaticString = #filePath, + line: UInt = #line + ) { + do { + _ = try operation() + XCTFail("expected rejection during the shutdown drain", file: file, line: line) + } catch PlatformWalletError.invalidHandle(let message) { + XCTAssertEqual( + message, + "manager shutdown is in progress; wallet creation rejected", + file: file, + line: line) + } catch { + XCTFail("unexpected error: \(error)", file: file, line: line) + } + } + + // The explicit synchronous function type prevents async-overload + // selection in this async test context. + let createFromMnemonicSynchronously: () throws -> ManagedPlatformWallet = { + try manager.createWallet(mnemonic: "sync", network: .testnet) + } + assertSynchronousShutdownRejection(createFromMnemonicSynchronously) + + do { + _ = try await manager.createWallet(mnemonic: "b", network: .testnet) + XCTFail("expected rejection during the shutdown drain") + } catch let error as PlatformWalletError { + guard case .invalidHandle(let message) = error else { + return XCTFail("expected invalidHandle, got \(error)") + } + XCTAssertEqual( + message, + "manager shutdown is in progress; wallet creation rejected") + } + + gate.signal() + _ = try await firstCreate.value + _ = await shutdownTask.value + XCTAssertEqual(recorder.count, 1, "rejected creates must never reach native work") + } + + // MARK: - Concurrent creates + + func testConcurrentCreatesBothSerializeAndPublish() async throws { + let recorder = CreateRecorder() + let manager = makeManager(handle: 21, createRecorder: recorder) + + async let first = manager.createWallet(mnemonic: "a", network: .testnet) + async let second = manager.createWallet(mnemonic: "b", network: .testnet) + let (w1, w2) = try await (first, second) + + XCTAssertEqual(recorder.count, 2) + XCTAssertEqual( + recorder.maxInFlight, 1, + "the shared serial queue must never run two native creates at once") + XCTAssertNotEqual(w1.walletId, w2.walletId) + XCTAssertEqual(manager.wallets.count, 2) + XCTAssertTrue(manager.wallets[w1.walletId] === w1) + XCTAssertTrue(manager.wallets[w2.walletId] === w2) + await manager.shutdown() + } +}