From 7add2606407f7445cd5e99aa7acf6504bcc13a3d Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 1 Sep 2026 16:30:26 +0200 Subject: [PATCH 01/17] feat(swift-sdk): add core wallet balance diagnostics --- packages/swift-sdk/Package.swift | 3 +- .../Persistence/DashModelContainer.swift | 110 +- .../CoreWalletDiagnosticAnalyzers.swift | 451 +++++ .../PlatformWalletManager.swift | 30 +- ...PlatformWalletManagerCoreDiagnostics.swift | 1551 +++++++++++++++++ .../PlatformWalletManagerSPV.swift | 64 +- .../PlatformWalletPersistenceHandler.swift | 58 +- .../CoreWalletDiagnosticAnalyzerTests.swift | 380 ++++ .../CoreWalletDiagnosticsTests.swift | 300 ++++ .../Dev1StoreUpgradeTests.swift | 75 + .../DashModel-v4.2.0-dev.1.sqlite.zlib | Bin 0 -> 62566 bytes .../SwiftDashSDKTests/Fixtures/README.md | 13 + 12 files changed, 3019 insertions(+), 16 deletions(-) create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWalletDiagnosticAnalyzers.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticAnalyzerTests.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticsTests.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/DashModel-v4.2.0-dev.1.sqlite.zlib create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/README.md diff --git a/packages/swift-sdk/Package.swift b/packages/swift-sdk/Package.swift index 253d84fcccd..9bc528b370b 100644 --- a/packages/swift-sdk/Package.swift +++ b/packages/swift-sdk/Package.swift @@ -32,7 +32,8 @@ let package = Package( .testTarget( name: "SwiftDashSDKTests", dependencies: ["SwiftDashSDK"], - path: "SwiftTests/SwiftDashSDKTests" + path: "SwiftTests/SwiftDashSDKTests", + resources: [.copy("Fixtures")] ), // Integration tests against a local dashmate devnet. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 0126c3d65be..a6e5dd9bd38 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -3,6 +3,37 @@ import SwiftData /// Factory for creating SwiftData model containers for Dash Platform persistence public enum DashModelContainer { + private struct StoreFileSizes { + let main: UInt64 + let wal: UInt64 + let shm: UInt64 + + var total: UInt64 { + [main, wal, shm].reduce(0) { partial, value in + let (sum, overflow) = partial.addingReportingOverflow(value) + return overflow ? UInt64.max : sum + } + } + } + + /// SQLite's durable state can be mostly in the WAL immediately after an + /// app kill, so the main file alone is not a useful corruption signal. + /// Read only sizes and never include any component of the device path. + private static func storeFileSizes(at storeURL: URL) -> StoreFileSizes { + func fileSize(at url: URL) -> UInt64 { + guard let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize, + size >= 0 + else { return 0 } + return UInt64(size) + } + + return StoreFileSizes( + main: fileSize(at: storeURL), + wal: fileSize(at: URL(fileURLWithPath: storeURL.path + "-wal")), + shm: fileSize(at: URL(fileURLWithPath: storeURL.path + "-shm")) + ) + } + /// Every registered schema version's model list, parameterised on the /// one model whose shape differs between versions. /// @@ -97,12 +128,79 @@ public enum DashModelContainer { ) // Always wire the migration plan so stores created by an older SDK - // advance through the registered versioned schemas. - return try ModelContainer( - for: schema, - migrationPlan: DashMigrationPlan.self, - configurations: [modelConfiguration] - ) + // advance through the registered versioned schemas. Record only + // metadata about the store — never its device path. + let storeURL = modelConfiguration.url + let existedBefore = FileManager.default.fileExists(atPath: storeURL.path) + let sizeBefore = storeFileSizes(at: storeURL) + let started = CFAbsoluteTimeGetCurrent() + do { + let container = try ModelContainer( + for: schema, + migrationPlan: DashMigrationPlan.self, + configurations: [modelConfiguration] + ) + let sizeAfter = storeFileSizes(at: storeURL) + SDKLogger.event( + "core_store_open_result", + category: .persistence, + fields: [ + "container_result": .publicText("opened"), + "container_reused": .boolean(false), + "duration_ms": .unsignedInteger(UInt64(max( + 0, + Int((CFAbsoluteTimeGetCurrent() - started) * 1_000) + ))), + "migration_result": .publicText( + existedBefore ? "store_open_succeeded" : "not_required_new_store" + ), + "result": .publicText("success"), + "store_existed_before_open": .boolean(existedBefore), + "store_main_size_bytes_after": .unsignedInteger(sizeAfter.main), + "store_main_size_bytes_before": .unsignedInteger(sizeBefore.main), + "store_shm_size_bytes_after": .unsignedInteger(sizeAfter.shm), + "store_shm_size_bytes_before": .unsignedInteger(sizeBefore.shm), + "store_size_bytes_after": .unsignedInteger(sizeAfter.total), + "store_size_bytes_before": .unsignedInteger(sizeBefore.total), + "store_wal_size_bytes_after": .unsignedInteger(sizeAfter.wal), + "store_wal_size_bytes_before": .unsignedInteger(sizeBefore.wal), + ] + ) + return container + } catch { + let sizeAfter = storeFileSizes(at: storeURL) + SDKLogger.event( + "core_store_open_result", + category: .persistence, + severity: .error, + fields: [ + "container_result": .publicText("open_failed"), + "container_reused": .boolean(false), + "duration_ms": .unsignedInteger(UInt64(max( + 0, + Int((CFAbsoluteTimeGetCurrent() - started) * 1_000) + ))), + "migration_result": .publicText( + existedBefore + ? "store_open_or_migration_failed" + : "not_attempted_new_store_create_failed" + ), + "result": .publicText("failure"), + "store_existed_before_open": .boolean(existedBefore), + "store_main_size_bytes_after": .unsignedInteger(sizeAfter.main), + "store_main_size_bytes_before": .unsignedInteger(sizeBefore.main), + "store_shm_size_bytes_after": .unsignedInteger(sizeAfter.shm), + "store_shm_size_bytes_before": .unsignedInteger(sizeBefore.shm), + "store_size_bytes_after": .unsignedInteger(sizeAfter.total), + "store_size_bytes_before": .unsignedInteger(sizeBefore.total), + "store_wal_size_bytes_after": .unsignedInteger(sizeAfter.wal), + "store_wal_size_bytes_before": .unsignedInteger(sizeBefore.wal), + ], + error: error, + redacting: [storeURL.path] + ) + throw error + } } /// Create an in-memory model container for testing diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWalletDiagnosticAnalyzers.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWalletDiagnosticAnalyzers.swift new file mode 100644 index 00000000000..70dc2540e65 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWalletDiagnosticAnalyzers.swift @@ -0,0 +1,451 @@ +import Foundation + +/// Value-only analyzers shared by the diagnostic logger and its unit tests. +/// Keeping comparison and truncation here makes the tests exercise the exact +/// decisions that produce `swift/run.log`, without requiring a live Rust +/// wallet handle. +enum CoreWalletDiagnosticAnalyzer { + struct TxoDiffDetail: Sendable { + let outpoint: Data + let reason: String + let row: CoreWalletDatabaseDiagnosticSnapshot.Txo + } + + struct TxoDiff: Sendable { + let commonCount: Int + let databaseAccountOnlyCount: Int + let memoryAccountOnlyCount: Int + let databaseOnlyCount: Int + let memoryOnlyCount: Int + let fieldMismatchCount: Int + let details: [TxoDiffDetail] + let emittedDetails: [TxoDiffDetail] + let truncatedCount: Int + } + + static func compareTxos( + database: [CoreWalletDatabaseDiagnosticSnapshot.Txo], + memory: [CoreWalletDatabaseDiagnosticSnapshot.Txo], + databaseAccounts: Set, + memoryAccounts: Set + ) -> TxoDiff { + var databaseByOutpoint: [Data: CoreWalletDatabaseDiagnosticSnapshot.Txo] = [:] + for row in database.sorted(by: txoOrder) where databaseByOutpoint[row.outpoint] == nil { + databaseByOutpoint[row.outpoint] = row + } + var memoryByOutpoint: [Data: CoreWalletDatabaseDiagnosticSnapshot.Txo] = [:] + for row in memory.sorted(by: txoOrder) where memoryByOutpoint[row.outpoint] == nil { + memoryByOutpoint[row.outpoint] = row + } + + let databaseOnly = databaseByOutpoint.keys + .filter { memoryByOutpoint[$0] == nil } + .sorted { $0.lexicographicallyPrecedes($1) } + let memoryOnly = memoryByOutpoint.keys + .filter { databaseByOutpoint[$0] == nil } + .sorted { $0.lexicographicallyPrecedes($1) } + + var mismatchDetails: [TxoDiffDetail] = [] + for outpoint in databaseByOutpoint.keys.sorted(by: { $0.lexicographicallyPrecedes($1) }) { + guard let databaseRow = databaseByOutpoint[outpoint], + let memoryRow = memoryByOutpoint[outpoint] + else { continue } + if databaseRow.amount != memoryRow.amount { + mismatchDetails.append(.init( + outpoint: outpoint, + reason: "amount_mismatch", + row: databaseRow + )) + } + if databaseRow.height != memoryRow.height { + mismatchDetails.append(.init( + outpoint: outpoint, + reason: "height_mismatch", + row: databaseRow + )) + } + if databaseRow.scriptPubKey != memoryRow.scriptPubKey { + mismatchDetails.append(.init( + outpoint: outpoint, + reason: "script_mismatch", + row: databaseRow + )) + } + if databaseRow.isLocked != memoryRow.isLocked { + mismatchDetails.append(.init( + outpoint: outpoint, + reason: "lock_mismatch", + row: databaseRow + )) + } + if databaseRow.account != memoryRow.account { + mismatchDetails.append(.init( + outpoint: outpoint, + reason: "account_mismatch", + row: databaseRow + )) + } + } + + var details = databaseOnly.compactMap { outpoint in + databaseByOutpoint[outpoint].map { + TxoDiffDetail(outpoint: outpoint, reason: "database_only", row: $0) + } + } + details.append(contentsOf: memoryOnly.compactMap { outpoint in + memoryByOutpoint[outpoint].map { + TxoDiffDetail(outpoint: outpoint, reason: "memory_only", row: $0) + } + }) + details.append(contentsOf: mismatchDetails) + details.sort(by: txoDetailOrder) + let limited = limitedTxoDetails(details) + + return TxoDiff( + commonCount: Set(databaseByOutpoint.keys).intersection(memoryByOutpoint.keys).count, + databaseAccountOnlyCount: databaseAccounts.subtracting(memoryAccounts).count, + memoryAccountOnlyCount: memoryAccounts.subtracting(databaseAccounts).count, + databaseOnlyCount: databaseOnly.count, + memoryOnlyCount: memoryOnly.count, + fieldMismatchCount: mismatchDetails.count, + details: details, + emittedDetails: limited.emitted, + truncatedCount: limited.truncated + ) + } + + struct AssetLockDiffDetail: Sendable { + let outpointDisplay: String + let reason: String + } + + struct AssetLockDiff: Sendable { + let details: [AssetLockDiffDetail] + let emittedDetails: [AssetLockDiffDetail] + let truncatedCount: Int + } + + static func compareAssetLocks( + database: [CoreWalletDatabaseDiagnosticSnapshot.AssetLock], + memory: [CoreWalletDatabaseDiagnosticSnapshot.AssetLock] + ) -> AssetLockDiff { + var databaseByOutpoint: [String: CoreWalletDatabaseDiagnosticSnapshot.AssetLock] = [:] + for row in database.sorted(by: assetLockOrder) + where databaseByOutpoint[row.outpointDisplay] == nil { + databaseByOutpoint[row.outpointDisplay] = row + } + var memoryByOutpoint: [String: CoreWalletDatabaseDiagnosticSnapshot.AssetLock] = [:] + for row in memory.sorted(by: assetLockOrder) + where memoryByOutpoint[row.outpointDisplay] == nil { + memoryByOutpoint[row.outpointDisplay] = row + } + + var details: [AssetLockDiffDetail] = [] + for outpoint in databaseByOutpoint.keys where memoryByOutpoint[outpoint] == nil { + details.append(.init(outpointDisplay: outpoint, reason: "database_only")) + } + for outpoint in memoryByOutpoint.keys where databaseByOutpoint[outpoint] == nil { + details.append(.init(outpointDisplay: outpoint, reason: "memory_only")) + } + for outpoint in databaseByOutpoint.keys.sorted() { + guard let databaseRow = databaseByOutpoint[outpoint], + let memoryRow = memoryByOutpoint[outpoint] + else { continue } + if databaseRow.fundingType != memoryRow.fundingType { + details.append(.init(outpointDisplay: outpoint, reason: "funding_type_mismatch")) + } + if databaseRow.status != memoryRow.status { + details.append(.init(outpointDisplay: outpoint, reason: "status_mismatch")) + } + if databaseRow.accountIndex != memoryRow.accountIndex { + details.append(.init(outpointDisplay: outpoint, reason: "account_index_mismatch")) + } + if databaseRow.registrationIndex != memoryRow.registrationIndex { + details.append(.init( + outpointDisplay: outpoint, + reason: "registration_index_mismatch" + )) + } + if databaseRow.amountDuffs != memoryRow.amountDuffs { + details.append(.init(outpointDisplay: outpoint, reason: "amount_mismatch")) + } + if databaseRow.hasProof != memoryRow.hasProof { + details.append(.init( + outpointDisplay: outpoint, + reason: "proof_presence_mismatch" + )) + } + } + details.sort(by: assetLockDetailOrder) + let limited = limitedAssetLockDetails(details) + return AssetLockDiff( + details: details, + emittedDetails: limited.emitted, + truncatedCount: limited.truncated + ) + } + + struct RestoreCandidate: Sendable { + enum RejectionReason: String, Sendable { + case missingAccount = "missing_account" + case invalidTxid = "invalid_txid" + case invalidAccountType = "invalid_account_type" + } + + let txo: CoreWalletDatabaseDiagnosticSnapshot.Txo + let accountType: UInt32? + let standardTag: UInt8? + let rejectionReason: RejectionReason? + let isCoinbase: Bool + let isConfirmed: Bool + let isInstantLocked: Bool + } + + struct RestoreBufferSummary: Sendable { + let candidateCount: Int + let candidateValueDuffs: UInt64 + let candidateBip44Count: Int + let candidateBip44ValueDuffs: UInt64 + let candidateCoinJoinCount: Int + let candidateCoinJoinValueDuffs: UInt64 + let builtCount: Int + let emittedCandidates: [RestoreCandidate] + let emittedValueDuffs: UInt64 + let emittedBip44Count: Int + let emittedBip44ValueDuffs: UInt64 + let emittedCoinJoinCount: Int + let emittedCoinJoinValueDuffs: UInt64 + let missingAccountCount: Int + let invalidTxidCount: Int + let invalidAccountTypeCount: Int + } + + static func summarizeRestoreBuffer( + candidates: [RestoreCandidate], + emittedCount: Int, + errored: Bool + ) -> RestoreBufferSummary { + let valid = candidates.filter { $0.rejectionReason == nil } + let emittedCandidates = errored ? [] : Array(valid.prefix(max(0, emittedCount))) + let candidateBip44 = candidates.filter { + $0.accountType == 0 && $0.standardTag == 0 + } + let candidateCoinJoin = candidates.filter { $0.accountType == 1 } + let emittedBip44 = emittedCandidates.filter { + $0.accountType == 0 && $0.standardTag == 0 + } + let emittedCoinJoin = emittedCandidates.filter { $0.accountType == 1 } + return RestoreBufferSummary( + candidateCount: candidates.count, + candidateValueDuffs: diagnosticSaturatingSum(candidates.map(\.txo.amount)), + candidateBip44Count: candidateBip44.count, + candidateBip44ValueDuffs: diagnosticSaturatingSum( + candidateBip44.map(\.txo.amount) + ), + candidateCoinJoinCount: candidateCoinJoin.count, + candidateCoinJoinValueDuffs: diagnosticSaturatingSum( + candidateCoinJoin.map(\.txo.amount) + ), + builtCount: emittedCount, + emittedCandidates: emittedCandidates, + emittedValueDuffs: diagnosticSaturatingSum(emittedCandidates.map(\.txo.amount)), + emittedBip44Count: emittedBip44.count, + emittedBip44ValueDuffs: diagnosticSaturatingSum(emittedBip44.map(\.txo.amount)), + emittedCoinJoinCount: emittedCoinJoin.count, + emittedCoinJoinValueDuffs: diagnosticSaturatingSum( + emittedCoinJoin.map(\.txo.amount) + ), + missingAccountCount: candidates.filter { + $0.rejectionReason == .missingAccount + }.count, + invalidTxidCount: candidates.filter { + $0.rejectionReason == .invalidTxid + }.count, + invalidAccountTypeCount: candidates.filter { + $0.rejectionReason == .invalidAccountType + }.count + ) + } + + struct DatabaseTxoAuditRow: Sendable { + let txo: CoreWalletDatabaseDiagnosticSnapshot.Txo + let hasParentTransaction: Bool + let walletIdMismatch: Bool + let isSpent: Bool + let hasSpendingTransaction: Bool + } + + struct DatabaseTxoAnomaly: Sendable { + let txo: CoreWalletDatabaseDiagnosticSnapshot.Txo + let reason: String + } + + struct DatabaseTxoAnomalyResult: Sendable { + let details: [DatabaseTxoAnomaly] + let emittedDetails: [DatabaseTxoAnomaly] + let truncatedCount: Int + + func count(reason: String) -> Int { + details.filter { $0.reason == reason }.count + } + } + + static func databaseTxoAnomalies( + _ rows: [DatabaseTxoAuditRow] + ) -> DatabaseTxoAnomalyResult { + var details: [DatabaseTxoAnomaly] = [] + for row in rows { + if row.txo.account == nil { + details.append(.init(txo: row.txo, reason: "missing_account")) + } + if !row.hasParentTransaction { + details.append(.init(txo: row.txo, reason: "missing_parent_transaction")) + } + if row.walletIdMismatch { + details.append(.init(txo: row.txo, reason: "wallet_id_mismatch")) + } + if row.isSpent && !row.hasSpendingTransaction { + details.append(.init( + txo: row.txo, + reason: "spent_without_spending_transaction" + )) + } + if !row.isSpent && row.hasSpendingTransaction { + details.append(.init( + txo: row.txo, + reason: "unspent_with_spending_transaction" + )) + } + if row.txo.outpoint.count != 36 { + details.append(.init(txo: row.txo, reason: "invalid_outpoint_length")) + } + if row.txo.scriptPubKey.isEmpty { + details.append(.init(txo: row.txo, reason: "empty_script_pubkey")) + } + } + details.sort { + if $0.reason != $1.reason { return $0.reason < $1.reason } + return $0.txo.outpoint.lexicographicallyPrecedes($1.txo.outpoint) + } + let grouped = Dictionary(grouping: details, by: \.reason) + var emitted: [DatabaseTxoAnomaly] = [] + var truncated = 0 + for reason in grouped.keys.sorted() { + let rows = grouped[reason] ?? [] + emitted.append(contentsOf: rows.prefix(CoreDiagnosticConstants.detailLimit)) + truncated += max(0, rows.count - CoreDiagnosticConstants.detailLimit) + } + return .init(details: details, emittedDetails: emitted, truncatedCount: truncated) + } + + struct ShieldedNote: Sendable { + let value: UInt64 + let isSpent: Bool + } + + struct ShieldedStoreSummary: Sendable { + let noteCount: Int + let spentNoteCount: Int + let spentValueCredits: UInt64 + let unspentNoteCount: Int + let unspentValueCredits: UInt64 + let outgoingNoteCount: Int + let activityCount: Int + let activityPendingCount: Int + let activityFailedCount: Int + let viewingKeyCount: Int + let subwalletSyncStateCount: Int + let maximumSyncWatermark: UInt64 + } + + static func summarizeShieldedStore( + notes: [ShieldedNote], + outgoingNoteCount: Int, + activityStatuses: [Int], + viewingKeyCount: Int, + syncWatermarks: [UInt64] + ) -> ShieldedStoreSummary { + let spent = notes.filter(\.isSpent) + let unspent = notes.filter { !$0.isSpent } + return ShieldedStoreSummary( + noteCount: notes.count, + spentNoteCount: spent.count, + spentValueCredits: diagnosticSaturatingSum(spent.map(\.value)), + unspentNoteCount: unspent.count, + unspentValueCredits: diagnosticSaturatingSum(unspent.map(\.value)), + outgoingNoteCount: outgoingNoteCount, + activityCount: activityStatuses.count, + activityPendingCount: activityStatuses.filter { $0 == 0 }.count, + activityFailedCount: activityStatuses.filter { $0 == 2 }.count, + viewingKeyCount: viewingKeyCount, + subwalletSyncStateCount: syncWatermarks.count, + maximumSyncWatermark: syncWatermarks.max() ?? 0 + ) + } + + private static func limitedTxoDetails( + _ details: [TxoDiffDetail] + ) -> (emitted: [TxoDiffDetail], truncated: Int) { + let grouped = Dictionary(grouping: details, by: \.reason) + var emitted: [TxoDiffDetail] = [] + var truncated = 0 + for reason in grouped.keys.sorted() { + let rows = (grouped[reason] ?? []).sorted(by: txoDetailOrder) + emitted.append(contentsOf: rows.prefix(CoreDiagnosticConstants.detailLimit)) + truncated += max(0, rows.count - CoreDiagnosticConstants.detailLimit) + } + return (emitted, truncated) + } + + private static func limitedAssetLockDetails( + _ details: [AssetLockDiffDetail] + ) -> (emitted: [AssetLockDiffDetail], truncated: Int) { + let grouped = Dictionary(grouping: details, by: \.reason) + var emitted: [AssetLockDiffDetail] = [] + var truncated = 0 + for reason in grouped.keys.sorted() { + let rows = (grouped[reason] ?? []).sorted(by: assetLockDetailOrder) + emitted.append(contentsOf: rows.prefix(CoreDiagnosticConstants.detailLimit)) + truncated += max(0, rows.count - CoreDiagnosticConstants.detailLimit) + } + return (emitted, truncated) + } + + private static func txoOrder( + _ lhs: CoreWalletDatabaseDiagnosticSnapshot.Txo, + _ rhs: CoreWalletDatabaseDiagnosticSnapshot.Txo + ) -> Bool { + if lhs.outpoint != rhs.outpoint { + return lhs.outpoint.lexicographicallyPrecedes(rhs.outpoint) + } + if lhs.amount != rhs.amount { return lhs.amount < rhs.amount } + if lhs.height != rhs.height { return lhs.height < rhs.height } + if lhs.scriptPubKey != rhs.scriptPubKey { + return lhs.scriptPubKey.lexicographicallyPrecedes(rhs.scriptPubKey) + } + if lhs.isLocked != rhs.isLocked { return !lhs.isLocked && rhs.isLocked } + let lhsAccount = lhs.account?.referenceMaterial ?? Data() + let rhsAccount = rhs.account?.referenceMaterial ?? Data() + return lhsAccount.lexicographicallyPrecedes(rhsAccount) + } + + private static func txoDetailOrder(_ lhs: TxoDiffDetail, _ rhs: TxoDiffDetail) -> Bool { + if lhs.reason != rhs.reason { return lhs.reason < rhs.reason } + return lhs.outpoint.lexicographicallyPrecedes(rhs.outpoint) + } + + private static func assetLockOrder( + _ lhs: CoreWalletDatabaseDiagnosticSnapshot.AssetLock, + _ rhs: CoreWalletDatabaseDiagnosticSnapshot.AssetLock + ) -> Bool { + lhs.outpointDisplay < rhs.outpointDisplay + } + + private static func assetLockDetailOrder( + _ lhs: AssetLockDiffDetail, + _ rhs: AssetLockDiffDetail + ) -> Bool { + if lhs.reason != rhs.reason { return lhs.reason < rhs.reason } + return lhs.outpointDisplay < rhs.outpointDisplay + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 8b7025e72b3..5e28fc5dc4a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -491,6 +491,17 @@ public class PlatformWalletManager: ObservableObject { } } + /// Diagnostics use the same admission/drain contract as other background + /// native work: once admitted, shutdown cannot consume the manager handle + /// until the read-only snapshot has finished on `destroyQueue`. + func admitCoreDiagnosticsNativeOp() throws { + try admitNativeOp("coreWalletDiagnostics") + } + + func finishCoreDiagnosticsNativeOp() { + finishNativeOp() + } + /// 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. @@ -1287,6 +1298,8 @@ public class PlatformWalletManager: ObservableObject { /// `createWallet` flow. @discardableResult public func loadFromPersistor() throws -> [ManagedPlatformWallet] { + let diagnosticPersistenceHandler = persistenceHandler + defer { diagnosticPersistenceHandler?.clearStartupCoreDiagnosticSnapshots() } // Same synchronous-admission gate as the sync creates: rejected // during the shutdown drain AND while an async native op is in // flight — a second Rust loader running concurrently with the one @@ -1369,6 +1382,13 @@ public class PlatformWalletManager: ObservableObject { } } + for managedWallet in restored { + emitCoreWalletDiagnosticsSynchronously( + for: managedWallet.walletId, + checkpoint: .startupPostRestore + ) + } + // Kick off a background catch-up pass for every persisted // asset lock at `statusRaw < 2`. Closes the SPV-restart gap: // the wallet's in-memory transactions map was just @@ -1510,12 +1530,13 @@ public class PlatformWalletManager: ObservableObject { /// and once admitted the teardown waits for the full transaction. @discardableResult public func loadFromPersistor() async throws -> [ManagedPlatformWallet] { + let handler = persistenceHandler + defer { handler?.clearStartupCoreDiagnosticSnapshots() } try ensureConfigured() try admitNativeOp("loadFromPersistor") defer { finishNativeOp() } let h = handle - let handler = persistenceHandler let calls = nativeLoadCalls // Direct continuation for the same FIFO reason as the async @@ -1588,6 +1609,13 @@ public class PlatformWalletManager: ObservableObject { ] ) + for managedWallet in restored { + await emitCoreWalletDiagnostics( + for: managedWallet.walletId, + checkpoint: .startupPostRestore + ) + } + catchUpStuckAssetLocks(wallets: restored) return restored } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift new file mode 100644 index 00000000000..4ff27a976bf --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift @@ -0,0 +1,1551 @@ +import CryptoKit +import DashSDKFFI +import Foundation +import SwiftData + +/// Named checkpoints make two exports from the same device directly +/// comparable without putting any user-controlled text in the log. +enum CoreWalletDiagnosticCheckpoint: String, Sendable { + case startupPreRestore = "startup_pre_restore" + case startupPostRestore = "startup_post_restore" + case preExport = "pre_export" +} + +/// Value-only copy of the SwiftData state used after the handler has released +/// its serial queue. No SwiftData model object crosses the queue boundary. +struct CoreWalletDatabaseDiagnosticSnapshot: Sendable { + struct AccountKey: Hashable, Sendable { + let typeTag: UInt32 + let standardTag: UInt8 + let index: UInt32 + let registrationIndex: UInt32 + let keyClass: UInt32 + let userIdentityId: Data + let friendIdentityId: Data + + init( + typeTag: UInt32, + standardTag: UInt8, + index: UInt32, + registrationIndex: UInt32, + keyClass: UInt32, + userIdentityId: Data, + friendIdentityId: Data + ) { + self.typeTag = typeTag + self.standardTag = standardTag + self.index = index + self.registrationIndex = registrationIndex + self.keyClass = keyClass + self.userIdentityId = Self.ffiIdentityBytes(userIdentityId) + self.friendIdentityId = Self.ffiIdentityBytes(friendIdentityId) + } + + var referenceMaterial: Data { + var data = Data() + data.appendLittleEndian(typeTag) + data.append(standardTag) + data.appendLittleEndian(index) + data.appendLittleEndian(registrationIndex) + data.appendLittleEndian(keyClass) + data.append(userIdentityId) + data.append(friendIdentityId) + return data + } + + private static func ffiIdentityBytes(_ value: Data) -> Data { + if value.count == 32 { return value } + if value.count > 32 { return Data(value.prefix(32)) } + var padded = Data(value) + padded.append(Data(repeating: 0, count: 32 - value.count)) + return padded + } + } + + struct Txo: Sendable { + let outpoint: Data + let amount: UInt64 + let height: UInt32 + let scriptPubKey: Data + let isLocked: Bool + let account: AccountKey? + } + + struct AssetLock: Sendable { + let outpointDisplay: String + let fundingType: Int + let status: Int + let accountIndex: UInt32 + let registrationIndex: UInt32 + /// `nil` represents a corrupt negative value in the signed legacy + /// SwiftData column; a valid in-memory `UInt64` can never equal it. + let amountDuffs: UInt64? + let hasProof: Bool + } + + let walletId: Data + let accounts: [AccountKey] + let unspentTxos: [Txo] + let assetLocks: [AssetLock] + let assetLocksAvailable: Bool +} + +enum CoreDiagnosticConstants { + static let detailLimit = 25 +} + +private extension Data { + mutating func appendLittleEndian(_ value: T) { + var littleEndian = value.littleEndian + Swift.withUnsafeBytes(of: &littleEndian) { append(contentsOf: $0) } + } +} + +func diagnosticSaturatingSum(_ values: S) -> UInt64 +where S.Element == UInt64 { + values.reduce(0) { partial, value in + let (sum, overflow) = partial.addingReportingOverflow(value) + return overflow ? UInt64.max : sum + } +} + +private func diagnosticSignedSaturatingSum(_ values: S) -> Int64 +where S.Element == Int64 { + values.reduce(0) { partial, value in + let (sum, overflow) = partial.addingReportingOverflow(value) + if !overflow { return sum } + return value >= 0 ? Int64.max : Int64.min + } +} + +func diagnosticFingerprint(_ records: [Data]) -> Data { + var hasher = SHA256() + for record in records.sorted(by: { $0.lexicographicallyPrecedes($1) }) { + var length = UInt64(record.count).littleEndian + Swift.withUnsafeBytes(of: &length) { hasher.update(bufferPointer: $0) } + hasher.update(data: record) + } + return Data(hasher.finalize()) +} + +func diagnosticTxoFingerprint( + outpoint: Data, + amount: UInt64, + height: UInt32, + scriptPubKey: Data, + isLocked: Bool, + account: CoreWalletDatabaseDiagnosticSnapshot.AccountKey? +) -> Data { + var data = Data() + data.appendLittleEndian(UInt64(outpoint.count)) + data.append(outpoint) + data.appendLittleEndian(amount) + data.appendLittleEndian(height) + data.append(isLocked ? 1 : 0) + data.appendLittleEndian(UInt64(scriptPubKey.count)) + data.append(scriptPubKey) + if let account { + data.append(1) + data.appendLittleEndian(UInt64(account.referenceMaterial.count)) + data.append(account.referenceMaterial) + } else { + data.append(0) + } + return data +} + +/// Canonical material for one exact `UtxoRestoreEntryFFI` row. The general +/// DB↔memory UTXO query cannot observe these three flags, so they live only in +/// this restore-specific fingerprint instead of creating false memory diffs. +func diagnosticRestoreTxoFingerprint( + _ candidate: CoreWalletDiagnosticAnalyzer.RestoreCandidate +) -> Data { + var data = diagnosticTxoFingerprint( + outpoint: candidate.txo.outpoint, + amount: candidate.txo.amount, + height: candidate.txo.height, + scriptPubKey: candidate.txo.scriptPubKey, + isLocked: candidate.txo.isLocked, + account: candidate.txo.account + ) + data.append(candidate.isCoinbase ? 1 : 0) + data.append(candidate.isConfirmed ? 1 : 0) + data.append(candidate.isInstantLocked ? 1 : 0) + return data +} + +extension PlatformWalletPersistenceHandler { + /// Main-actor-friendly entry point used by manual log export. The handler's + /// serial queue owns the ModelContext; only a Sendable value snapshot is + /// resumed across the continuation. + func emitCoreWalletDatabaseDiagnostics( + walletId: Data, + checkpoint: CoreWalletDiagnosticCheckpoint + ) async -> CoreWalletDatabaseDiagnosticSnapshot? { + await withCheckedContinuation { continuation in + serialQueue.async { [self] in + let snapshot = autoreleasepool { () -> CoreWalletDatabaseDiagnosticSnapshot? in + if checkpoint == .startupPostRestore, + let cached = startupCoreDiagnosticSnapshots.removeValue(forKey: walletId) { + SDKLogger.event( + "core_db_startup_snapshot_reused", + category: .persistence, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "wallet_reference": .reference(walletId), + ] + ) + return cached + } + return emitCoreWalletDatabaseDiagnosticsOnQueue( + walletId: walletId, + checkpoint: checkpoint + ) + } + continuation.resume(returning: snapshot) + } + } + } + + /// Synchronous companion for the legacy synchronous restore overload. + func emitCoreWalletDatabaseDiagnostics( + walletId: Data, + checkpoint: CoreWalletDiagnosticCheckpoint + ) -> CoreWalletDatabaseDiagnosticSnapshot? { + onQueue { + if checkpoint == .startupPostRestore, + let cached = startupCoreDiagnosticSnapshots.removeValue(forKey: walletId) { + SDKLogger.event( + "core_db_startup_snapshot_reused", + category: .persistence, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "wallet_reference": .reference(walletId), + ] + ) + return cached + } + return emitCoreWalletDatabaseDiagnosticsOnQueue( + walletId: walletId, + checkpoint: checkpoint + ) + } + } + + /// Must be called while `serialQueue` is held. `loadWalletList` uses this + /// directly, avoiding a recursive `serialQueue.sync` deadlock. + @discardableResult + func emitCoreWalletDatabaseDiagnosticsOnQueue( + walletId: Data, + checkpoint: CoreWalletDiagnosticCheckpoint + ) -> CoreWalletDatabaseDiagnosticSnapshot? { + // A previous restore can fail after the pre-snapshot was cached but + // before post-restore consumes it. Never let a later attempt compare + // Rust against that stale value. + if checkpoint == .startupPreRestore { + startupCoreDiagnosticSnapshots.removeValue(forKey: walletId) + } + do { + let walletDescriptor = FetchDescriptor( + predicate: PersistentWallet.predicate(walletId: walletId) + ) + guard let wallet = try backgroundContext.fetch(walletDescriptor).first else { + SDKLogger.event( + "core_diagnostics_unavailable", + category: .persistence, + severity: .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "reason": .publicText("wallet_not_found"), + "wallet_reference": .reference(walletId), + ] + ) + return nil + } + + let allTxos = try backgroundContext.fetch(FetchDescriptor()) + let walletTxos = allTxos.filter { + $0.walletId == walletId || Self.relationshipWalletId(of: $0) == walletId + } + // Walking every transaction relationship is deliberately export-only. + // A heavily mixed wallet can have enough history for this traversal to + // stall restore, which is precisely the failure this instrumentation is + // intended to diagnose rather than reproduce. + let allTransactions: [PersistentTransaction]? + let walletTransactions: [PersistentTransaction]? + if checkpoint == .preExport { + do { + let fetched = try backgroundContext.fetch( + FetchDescriptor() + ) + allTransactions = fetched + walletTransactions = fetched.filter { + Self.walletOwnsTransaction(walletId: walletId, transaction: $0) + } + } catch { + allTransactions = nil + walletTransactions = nil + SDKLogger.event( + "core_owned_output_audit_summary", + category: .persistence, + severity: .warning, + fields: [ + "audit_incomplete": .boolean(true), + "checkpoint": .publicText(checkpoint.rawValue), + "reason": .publicText("transaction_fetch_failed"), + "wallet_reference": .reference(walletId), + ] + ) + } + } else { + allTransactions = nil + walletTransactions = nil + } + let pending: [PersistentPendingInput]? + do { + pending = try backgroundContext.fetch( + FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + ) + } catch { + pending = nil + } + + let confirmed = walletTxos.filter(\.isConfirmed) + let unconfirmed = walletTxos.filter { !$0.isConfirmed } + let spent = walletTxos.filter(\.isSpent) + let unspent = walletTxos.filter { !$0.isSpent } + let locked = walletTxos.filter(\.isLocked) + let txoFingerprint = diagnosticFingerprint(walletTxos.map { + diagnosticTxoFingerprint( + outpoint: $0.outpoint, + amount: $0.amount, + height: $0.height, + scriptPubKey: $0.scriptPubKey, + isLocked: $0.isLocked, + account: Self.diagnosticAccountKey($0.account) + ) + }) + let now = Date() + let oldestPendingAge: Int64 + if let pending { + oldestPendingAge = pending.compactMap { row -> Int64? in + let interval = now.timeIntervalSince(row.createdAt) + guard interval.isFinite else { return nil } + if interval <= 0 { return 0 } + if interval >= Double(Int64.max) { return Int64.max } + return Int64(interval) + }.max() ?? 0 + } else { + oldestPendingAge = -1 + } + + SDKLogger.event( + "core_db_wallet_snapshot", + category: .persistence, + fields: [ + "account_count": .integer(Int64(wallet.accounts.count)), + "birth_height": .unsignedInteger(UInt64(wallet.birthHeight)), + "checkpoint": .publicText(checkpoint.rawValue), + "confirmed_count": .integer(Int64(confirmed.count)), + "confirmed_value_duffs": .unsignedInteger( + diagnosticSaturatingSum(confirmed.map(\.amount)) + ), + "locked_count": .integer(Int64(locked.count)), + "locked_value_duffs": .unsignedInteger( + diagnosticSaturatingSum(locked.map(\.amount)) + ), + "oldest_pending_input_age_seconds": .integer(oldestPendingAge), + "pending_input_count": .integer(pending.map { Int64($0.count) } ?? -1), + "pending_query_available": .boolean(pending != nil), + "spent_count": .integer(Int64(spent.count)), + "spent_value_duffs": .unsignedInteger( + diagnosticSaturatingSum(spent.map(\.amount)) + ), + "synced_height": .unsignedInteger(UInt64(wallet.syncedHeight)), + "transaction_count": .integer( + walletTransactions.map { Int64($0.count) } ?? -1 + ), + "transaction_scan_available": .boolean(walletTransactions != nil), + "txo_count": .integer(Int64(walletTxos.count)), + "txo_fingerprint": .reference(txoFingerprint), + "unconfirmed_count": .integer(Int64(unconfirmed.count)), + "unconfirmed_value_duffs": .unsignedInteger( + diagnosticSaturatingSum(unconfirmed.map(\.amount)) + ), + "unspent_count": .integer(Int64(unspent.count)), + "unspent_value_duffs": .unsignedInteger( + diagnosticSaturatingSum(unspent.map(\.amount)) + ), + "wallet_reference": .reference(walletId), + ] + ) + + let sortedAccounts = wallet.accounts.sorted { + ($0.accountType, $0.standardTag, $0.accountIndex, + $0.registrationIndex, $0.keyClass) + < ($1.accountType, $1.standardTag, $1.accountIndex, + $1.registrationIndex, $1.keyClass) + } + for account in sortedAccounts { + let key = Self.diagnosticAccountKey(account)! + let accountTxos = walletTxos.filter { $0.account === account } + let accountSpent = accountTxos.filter(\.isSpent) + let accountUnspent = accountTxos.filter { !$0.isSpent } + let accountConfirmed = accountTxos.filter(\.isConfirmed) + let accountUnconfirmed = accountTxos.filter { !$0.isConfirmed } + let accountLocked = accountTxos.filter(\.isLocked) + let externalAddresses = account.coreAddresses.filter { $0.poolTypeTag == 0 } + let internalAddresses = account.coreAddresses.filter { $0.poolTypeTag == 1 } + let accountFingerprint = diagnosticFingerprint(accountTxos.map { + diagnosticTxoFingerprint( + outpoint: $0.outpoint, + amount: $0.amount, + height: $0.height, + scriptPubKey: $0.scriptPubKey, + isLocked: $0.isLocked, + account: key + ) + }) + SDKLogger.event( + "core_db_account_snapshot", + category: .persistence, + fields: [ + "account_index": .unsignedInteger(UInt64(account.accountIndex)), + "account_reference": .reference(key.referenceMaterial), + "account_type": .unsignedInteger(UInt64(account.accountType)), + "checkpoint": .publicText(checkpoint.rawValue), + "confirmed_count": .integer(Int64(accountConfirmed.count)), + "confirmed_value_duffs": .unsignedInteger( + diagnosticSaturatingSum(accountConfirmed.map(\.amount)) + ), + "external_address_count": .integer(Int64(externalAddresses.count)), + "external_highest_used": .integer(Int64(account.externalHighestUsed)), + "internal_address_count": .integer(Int64(internalAddresses.count)), + "internal_highest_used": .integer(Int64(account.internalHighestUsed)), + "locked_count": .integer(Int64(accountLocked.count)), + "locked_value_duffs": .unsignedInteger( + diagnosticSaturatingSum(accountLocked.map(\.amount)) + ), + "registration_index": .unsignedInteger(UInt64(account.registrationIndex)), + "spent_count": .integer(Int64(accountSpent.count)), + "spent_value_duffs": .unsignedInteger( + diagnosticSaturatingSum(accountSpent.map(\.amount)) + ), + "standard_tag": .unsignedInteger(UInt64(account.standardTag)), + "txo_fingerprint": .reference(accountFingerprint), + "unconfirmed_count": .integer(Int64(accountUnconfirmed.count)), + "unconfirmed_value_duffs": .unsignedInteger( + diagnosticSaturatingSum(accountUnconfirmed.map(\.amount)) + ), + "unspent_count": .integer(Int64(accountUnspent.count)), + "unspent_value_duffs": .unsignedInteger( + diagnosticSaturatingSum(accountUnspent.map(\.amount)) + ), + "used_address_count": .integer( + Int64(account.coreAddresses.filter(\.isUsed).count) + ), + "wallet_reference": .reference(walletId), + ] + ) + } + + Self.logTxoAnomalies( + walletId: walletId, + checkpoint: checkpoint, + txos: walletTxos + ) + // Decoding a heavily mixed wallet's full transaction history can + // be expensive. The exact #4438 audit is needed for the manually + // exported artifact, not for restoring Rust, so keep startup's + // persistence queue limited to lightweight summaries. + if checkpoint == .preExport, + let allTransactions { + Self.auditCoinJoinOwnedBip44Outputs( + wallet: wallet, + walletId: walletId, + checkpoint: checkpoint, + allTxos: allTxos, + allTransactions: allTransactions + ) + } + + let assetLocks: [CoreWalletDatabaseDiagnosticSnapshot.AssetLock] + let assetLocksAvailable: Bool + do { + assetLocks = try Self.logAssetLockDatabaseSnapshot( + context: backgroundContext, + walletId: walletId, + checkpoint: checkpoint, + walletTransactions: walletTransactions + ) + assetLocksAvailable = true + } catch { + assetLocks = [] + assetLocksAvailable = false + SDKLogger.event( + "asset_lock_db_snapshot", + category: .persistence, + severity: .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "query_available": .boolean(false), + "wallet_reference": .reference(walletId), + ] + ) + } + do { + try Self.logShieldedStoreSnapshot( + context: backgroundContext, + walletId: walletId, + checkpoint: checkpoint + ) + } catch { + SDKLogger.event( + "shielded_store_snapshot", + category: .shielded, + severity: .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "query_available": .boolean(false), + "wallet_reference": .reference(walletId), + ] + ) + } + + let snapshot = CoreWalletDatabaseDiagnosticSnapshot( + walletId: walletId, + accounts: sortedAccounts.compactMap(Self.diagnosticAccountKey), + unspentTxos: unspent.map { + CoreWalletDatabaseDiagnosticSnapshot.Txo( + outpoint: $0.outpoint, + amount: $0.amount, + height: $0.height, + scriptPubKey: $0.scriptPubKey, + isLocked: $0.isLocked, + account: Self.diagnosticAccountKey($0.account) + ) + }, + assetLocks: assetLocks, + assetLocksAvailable: assetLocksAvailable + ) + if checkpoint == .startupPreRestore { + startupCoreDiagnosticSnapshots[walletId] = snapshot + } + return snapshot + } catch { + SDKLogger.event( + "core_diagnostics_unavailable", + category: .persistence, + severity: .error, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "reason": .publicText("swiftdata_fetch_failed"), + "wallet_reference": .reference(walletId), + ] + ) + return nil + } + } + + /// Logs the exact UTXO slice handed to Rust, independently of the broader + /// database snapshot. This sits after compact-write, so `emitted_count` + /// cannot be confused with the number of fetched candidates. + func logCoreRestoreBufferSnapshotOnQueue( + walletId: Data, + rows: [PersistentTxo], + emittedCount: Int, + errored: Bool + ) { + let candidates = rows.map { row in + let rejection: CoreWalletDiagnosticAnalyzer.RestoreCandidate.RejectionReason? + if row.account == nil { + rejection = .missingAccount + } else if row.txid.count != 32 { + rejection = .invalidTxid + } else if let account = row.account, + UInt8(exactly: account.accountType) == nil { + rejection = .invalidAccountType + } else { + rejection = nil + } + return CoreWalletDiagnosticAnalyzer.RestoreCandidate( + txo: CoreWalletDatabaseDiagnosticSnapshot.Txo( + outpoint: PersistentTxo.makeOutpoint(txid: row.txid, vout: row.vout), + amount: row.amount, + height: row.height, + scriptPubKey: row.scriptPubKey, + isLocked: row.isLocked, + account: Self.diagnosticAccountKey(row.account) + ), + accountType: row.account?.accountType, + standardTag: row.account?.standardTag, + rejectionReason: rejection, + isCoinbase: row.isCoinbase, + isConfirmed: row.isConfirmed, + isInstantLocked: row.isInstantLocked + ) + } + // A validation error deallocates the compact buffer and aborts the + // whole callback, so zero rows were actually handed to Rust even if + // some valid rows preceded the corrupt one. + let summary = CoreWalletDiagnosticAnalyzer.summarizeRestoreBuffer( + candidates: candidates, + emittedCount: emittedCount, + errored: errored + ) + let emittedMaterials = summary.emittedCandidates.map(diagnosticRestoreTxoFingerprint) + let hasRejectedRows = summary.missingAccountCount > 0 + || summary.invalidTxidCount > 0 + || summary.invalidAccountTypeCount > 0 + + SDKLogger.event( + "core_restore_buffer_snapshot", + category: .persistence, + severity: errored ? .error : (hasRejectedRows ? .warning : .info), + fields: [ + "candidate_count": .integer(Int64(summary.candidateCount)), + "candidate_bip44_count": .integer(Int64(summary.candidateBip44Count)), + "candidate_bip44_value_duffs": .unsignedInteger( + summary.candidateBip44ValueDuffs + ), + "candidate_coinjoin_count": .integer(Int64(summary.candidateCoinJoinCount)), + "candidate_coinjoin_value_duffs": .unsignedInteger( + summary.candidateCoinJoinValueDuffs + ), + "candidate_value_duffs": .unsignedInteger(summary.candidateValueDuffs), + "built_count": .integer(Int64(summary.builtCount)), + "checkpoint": .publicText(CoreWalletDiagnosticCheckpoint.startupPreRestore.rawValue), + "emitted_count": .integer(Int64(summary.emittedCandidates.count)), + "emitted_bip44_count": .integer(Int64(summary.emittedBip44Count)), + "emitted_bip44_value_duffs": .unsignedInteger( + summary.emittedBip44ValueDuffs + ), + "emitted_coinjoin_count": .integer(Int64(summary.emittedCoinJoinCount)), + "emitted_coinjoin_value_duffs": .unsignedInteger( + summary.emittedCoinJoinValueDuffs + ), + "emitted_fingerprint": .reference(diagnosticFingerprint(emittedMaterials)), + "emitted_value_duffs": .unsignedInteger(summary.emittedValueDuffs), + "errored": .boolean(errored), + "skipped_invalid_account_type_count": .integer( + Int64(summary.invalidAccountTypeCount) + ), + "skipped_invalid_txid_count": .integer(Int64(summary.invalidTxidCount)), + "skipped_missing_account_count": .integer(Int64(summary.missingAccountCount)), + "wallet_reference": .reference(walletId), + ] + ) + } + + private static func diagnosticAccountKey( + _ account: PersistentAccount? + ) -> CoreWalletDatabaseDiagnosticSnapshot.AccountKey? { + guard let account else { return nil } + return CoreWalletDatabaseDiagnosticSnapshot.AccountKey( + typeTag: account.accountType, + standardTag: account.standardTag, + index: account.accountIndex, + registrationIndex: account.registrationIndex, + keyClass: account.keyClass, + userIdentityId: account.userIdentityId, + friendIdentityId: account.friendIdentityId + ) + } + + /// Read the relationship-owned wallet independently of the denormalized + /// `PersistentTxo.walletId`. Diagnostics must compare the two sources; + /// `resolvedWalletId(of:)` deliberately prefers the denormalized value and + /// would therefore hide exactly the corruption we are trying to expose. + private static func relationshipWalletId(of txo: PersistentTxo) -> Data? { + let account: PersistentAccount? = txo.account + guard let account else { return nil } + let wallet: PersistentWallet? = account.wallet + return wallet?.walletId + } + + private static func logTxoAnomalies( + walletId: Data, + checkpoint: CoreWalletDiagnosticCheckpoint, + txos: [PersistentTxo] + ) { + let result = CoreWalletDiagnosticAnalyzer.databaseTxoAnomalies(txos.map { txo in + let relationshipWalletId = relationshipWalletId(of: txo) + return CoreWalletDiagnosticAnalyzer.DatabaseTxoAuditRow( + txo: CoreWalletDatabaseDiagnosticSnapshot.Txo( + outpoint: txo.outpoint, + amount: txo.amount, + height: txo.height, + scriptPubKey: txo.scriptPubKey, + isLocked: txo.isLocked, + account: diagnosticAccountKey(txo.account) + ), + hasParentTransaction: txo.transaction != nil, + walletIdMismatch: !txo.walletId.isEmpty + && relationshipWalletId != nil + && txo.walletId != relationshipWalletId, + isSpent: txo.isSpent, + hasSpendingTransaction: txo.spendingTransaction != nil + ) + }) + SDKLogger.event( + "core_db_anomaly_summary", + category: .persistence, + severity: result.details.isEmpty ? .info : .warning, + fields: [ + "anomaly_count": .integer(Int64(result.details.count)), + "checkpoint": .publicText(checkpoint.rawValue), + "detail_count": .integer(Int64(result.emittedDetails.count)), + "empty_script_count": .integer(Int64(result.count(reason: "empty_script_pubkey"))), + "invalid_outpoint_count": .integer(Int64( + result.count(reason: "invalid_outpoint_length") + )), + "missing_account_count": .integer(Int64(result.count(reason: "missing_account"))), + "missing_parent_transaction_count": .integer(Int64( + result.count(reason: "missing_parent_transaction") + )), + "spent_relation_mismatch_count": .integer(Int64( + result.count(reason: "spent_without_spending_transaction") + + result.count(reason: "unspent_with_spending_transaction") + )), + "truncated_count": .integer(Int64(result.truncatedCount)), + "wallet_mismatch_count": .integer(Int64( + result.count(reason: "wallet_id_mismatch") + )), + "wallet_reference": .reference(walletId), + ] + ) + for detail in result.emittedDetails { + SDKLogger.event( + "core_db_txo_anomaly", + category: .persistence, + severity: .warning, + fields: [ + "amount_duffs": .unsignedInteger(detail.txo.amount), + "checkpoint": .publicText(checkpoint.rawValue), + "height": .unsignedInteger(UInt64(detail.txo.height)), + "outpoint_reference": .reference(detail.txo.outpoint), + "reason": .publicText(detail.reason), + "wallet_reference": .reference(walletId), + ] + ) + } + } + + /// Exact detector for dashpay/platform#4438. It does not trust the + /// transaction's persisted role: it decodes inputs, proves at least one + /// spends a known CoinJoin TXO, then checks every decoded output against + /// the persisted BIP44 address pool and the TXO table. + private static func auditCoinJoinOwnedBip44Outputs( + wallet: PersistentWallet, + walletId: Data, + checkpoint: CoreWalletDiagnosticCheckpoint, + allTxos: [PersistentTxo], + allTransactions: [PersistentTransaction] + ) { + let coinJoinOutpoints = Set(allTxos.compactMap { txo -> Data? in + guard relationshipWalletId(of: txo) == walletId, + txo.account?.accountType == 1 + else { return nil } + return txo.outpoint + }) + var bip44Addresses: [String: PersistentAccount] = [:] + for account in wallet.accounts where account.accountType == 0 && account.standardTag == 0 { + for coreAddress in account.coreAddresses where bip44Addresses[coreAddress.address] == nil { + bip44Addresses[coreAddress.address] = account + } + } + let txoByOutpoint = Dictionary(grouping: allTxos, by: \.outpoint) + + var candidateCount = 0 + var decodeFailureCount = 0 + var ownedOutputCount = 0 + var ownedOutputValue: UInt64 = 0 + var validCount = 0 + var anomalies: [(tx: PersistentTransaction, vout: UInt32, amount: UInt64, + outpoint: Data, reason: String)] = [] + + guard let network = wallet.network else { + SDKLogger.event( + "core_owned_output_audit_summary", + category: .persistence, + severity: .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "reason": .publicText("wallet_network_unknown"), + "wallet_reference": .reference(walletId), + ] + ) + return + } + + for transaction in allTransactions where !transaction.transactionData.isEmpty { + let decoded: DecodedTransaction + do { + decoded = try TransactionDecoder.decode(transaction.transactionData, network: network) + } catch { + // Only count decode failures for rows already associated with + // this wallet; unrelated-wallet corruption must not pollute + // this wallet's audit result. + if walletOwnsTransaction(walletId: walletId, transaction: transaction) { + decodeFailureCount += 1 + } + continue + } + let spendsCoinJoin = decoded.inputs.contains { input in + coinJoinOutpoints.contains( + PersistentTxo.makeOutpoint(txid: input.prevTxid, vout: input.prevVout) + ) + } + guard spendsCoinJoin else { continue } + candidateCount += 1 + + for (index, output) in decoded.outputs.enumerated() { + guard let address = output.address, + let expectedAccount = bip44Addresses[address] + else { continue } + ownedOutputCount += 1 + let (newValue, overflow) = ownedOutputValue.addingReportingOverflow(output.valueDuffs) + ownedOutputValue = overflow ? UInt64.max : newValue + let vout = UInt32(index) + let outpoint = PersistentTxo.makeOutpoint(txid: decoded.txid, vout: vout) + guard let rows = txoByOutpoint[outpoint], let row = rows.first else { + anomalies.append((transaction, vout, output.valueDuffs, outpoint, "missing_txo")) + continue + } + guard relationshipWalletId(of: row) == walletId, + row.walletId.isEmpty || row.walletId == walletId + else { + anomalies.append((transaction, vout, output.valueDuffs, outpoint, "wrong_wallet")) + continue + } + guard row.account === expectedAccount, + row.account?.accountType == 0, + row.account?.standardTag == 0 + else { + anomalies.append((transaction, vout, output.valueDuffs, outpoint, "wrong_account")) + continue + } + guard row.amount == output.valueDuffs else { + anomalies.append((transaction, vout, output.valueDuffs, outpoint, "amount_mismatch")) + continue + } + guard row.scriptPubKey == output.scriptPubkey else { + anomalies.append((transaction, vout, output.valueDuffs, outpoint, "script_mismatch")) + continue + } + validCount += 1 + } + } + + anomalies.sort { + if $0.outpoint != $1.outpoint { + return $0.outpoint.lexicographicallyPrecedes($1.outpoint) + } + return $0.reason < $1.reason + } + let anomalyGroups = Dictionary(grouping: anomalies, by: { $0.reason }) + let truncatedAnomalyCount = anomalyGroups.values.reduce(0) { + $0 + max(0, $1.count - CoreDiagnosticConstants.detailLimit) + } + let missingCount = anomalies.filter { $0.reason == "missing_txo" }.count + let missingValue = diagnosticSaturatingSum(anomalies.compactMap { + $0.reason == "missing_txo" ? $0.amount : nil + }) + SDKLogger.event( + "core_owned_output_audit_summary", + category: .persistence, + severity: anomalies.isEmpty && decodeFailureCount == 0 ? .info : .warning, + fields: [ + "audit_incomplete": .boolean(decodeFailureCount > 0), + "candidate_transaction_count": .integer(Int64(candidateCount)), + "checkpoint": .publicText(checkpoint.rawValue), + "coinjoin_to_bip44_missing_count": .integer(Int64(missingCount)), + "coinjoin_to_bip44_missing_value_duffs": .unsignedInteger(missingValue), + "decode_failure_count": .integer(Int64(decodeFailureCount)), + "owned_bip44_output_count": .integer(Int64(ownedOutputCount)), + "owned_bip44_output_value_duffs": .unsignedInteger(ownedOutputValue), + "persisted_valid_count": .integer(Int64(validCount)), + "total_anomaly_count": .integer(Int64(anomalies.count)), + "truncated_count": .integer(Int64(truncatedAnomalyCount)), + "wallet_reference": .reference(walletId), + ] + ) + for reason in anomalyGroups.keys.sorted() { + for anomaly in (anomalyGroups[reason] ?? []).prefix(CoreDiagnosticConstants.detailLimit) { + SDKLogger.event( + "core_owned_output_anomaly", + category: .persistence, + severity: .warning, + fields: [ + "amount_duffs": .unsignedInteger(anomaly.amount), + "block_height": .unsignedInteger(UInt64(anomaly.tx.blockHeight)), + "checkpoint": .publicText(checkpoint.rawValue), + "input_account_kind": .publicText("coinjoin"), + "outpoint_reference": .reference(anomaly.outpoint), + "output_account_kind": .publicText("bip44"), + "reason": .publicText(reason), + "transaction_context": .unsignedInteger(UInt64(anomaly.tx.context)), + "transaction_reference": .reference(anomaly.tx.txid), + "vout": .unsignedInteger(UInt64(anomaly.vout)), + "wallet_reference": .reference(walletId), + ] + ) + } + } + } + + private static func logAssetLockDatabaseSnapshot( + context: ModelContext, + walletId: Data, + checkpoint: CoreWalletDiagnosticCheckpoint, + walletTransactions: [PersistentTransaction]? + ) throws -> [CoreWalletDatabaseDiagnosticSnapshot.AssetLock] { + let rows = try context.fetch( + FetchDescriptor( + predicate: PersistentAssetLock.predicate(walletId: walletId) + ) + ) + SDKLogger.event( + "asset_lock_db_snapshot", + category: .persistence, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "core_type_8_transaction_count": .integer( + walletTransactions.map { Int64($0.filter(\.isAssetLock).count) } ?? -1 + ), + "core_transaction_scan_available": .boolean(walletTransactions != nil), + "lock_count": .integer(Int64(rows.count)), + "proof_present_count": .integer(Int64(rows.filter { + $0.proofBytes?.isEmpty == false + }.count)), + "query_available": .boolean(true), + "shielded_funding_count": .integer(Int64(rows.filter { + $0.fundingTypeRaw == 5 + }.count)), + "transaction_bytes_present_count": .integer(Int64(rows.filter { + !$0.transactionBytes.isEmpty + }.count)), + "wallet_reference": .reference(walletId), + ] + ) + + let groups = Dictionary(grouping: rows) { + "\($0.fundingTypeRaw):\($0.statusRaw)" + } + for key in groups.keys.sorted() { + guard let group = groups[key], let first = group.first else { continue } + SDKLogger.event( + "asset_lock_db_group", + category: .persistence, + fields: [ + "amount_duffs": .integer( + diagnosticSignedSaturatingSum(group.map(\.amountDuffs)) + ), + "checkpoint": .publicText(checkpoint.rawValue), + "count": .integer(Int64(group.count)), + "funding_type": .integer(Int64(first.fundingTypeRaw)), + "status": .integer(Int64(first.statusRaw)), + "wallet_reference": .reference(walletId), + ] + ) + } + return rows.map { + CoreWalletDatabaseDiagnosticSnapshot.AssetLock( + outpointDisplay: $0.outPointHex, + fundingType: $0.fundingTypeRaw, + status: $0.statusRaw, + accountIndex: UInt32(bitPattern: $0.accountIndexRaw), + registrationIndex: UInt32(bitPattern: $0.identityIndexRaw), + amountDuffs: UInt64(exactly: $0.amountDuffs), + hasProof: $0.proofBytes?.isEmpty == false + ) + } + } + + private static func logShieldedStoreSnapshot( + context: ModelContext, + walletId: Data, + checkpoint: CoreWalletDiagnosticCheckpoint + ) throws { + let notes = try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + )) + let outgoing = try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + )) + let states = try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + )) + let activity = try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + )) + let viewingKeys = try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + )) + let summary = CoreWalletDiagnosticAnalyzer.summarizeShieldedStore( + notes: notes.map { .init(value: $0.value, isSpent: $0.isSpent) }, + outgoingNoteCount: outgoing.count, + activityStatuses: activity.map(\.status), + viewingKeyCount: viewingKeys.count, + syncWatermarks: states.map(\.lastSyncedIndex) + ) + SDKLogger.event( + "shielded_store_snapshot", + category: .shielded, + fields: [ + "activity_count": .integer(Int64(summary.activityCount)), + "activity_failed_count": .integer(Int64(summary.activityFailedCount)), + "activity_pending_count": .integer(Int64(summary.activityPendingCount)), + "checkpoint": .publicText(checkpoint.rawValue), + "maximum_sync_watermark": .unsignedInteger(summary.maximumSyncWatermark), + "note_count": .integer(Int64(summary.noteCount)), + "outgoing_note_count": .integer(Int64(summary.outgoingNoteCount)), + "query_available": .boolean(true), + "spent_note_count": .integer(Int64(summary.spentNoteCount)), + "spent_value_credits": .unsignedInteger(summary.spentValueCredits), + "subwallet_sync_state_count": .integer(Int64(summary.subwalletSyncStateCount)), + "unspent_note_count": .integer(Int64(summary.unspentNoteCount)), + "unspent_value_credits": .unsignedInteger(summary.unspentValueCredits), + "viewing_key_count": .integer(Int64(summary.viewingKeyCount)), + "wallet_reference": .reference(walletId), + ] + ) + } +} + +// MARK: - Rust memory comparison + +@MainActor +extension PlatformWalletManager { + /// Emit a best-effort, read-only snapshot immediately before a diagnostic + /// export. The method intentionally never throws: a failed sub-query is a + /// diagnostic fact and is logged as `unavailable`, not reported as zero. + public func emitCoreWalletDiagnostics(for walletId: Data) async { + await emitCoreWalletDiagnostics(for: walletId, checkpoint: .preExport) + } + + func emitCoreWalletDiagnostics( + for walletId: Data, + checkpoint: CoreWalletDiagnosticCheckpoint + ) async { + guard walletId.count == 32, let handler = persistence else { + SDKLogger.event( + "core_diagnostics_unavailable", + category: .persistence, + severity: .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "reason": .publicText("invalid_wallet_or_persistence_disabled"), + "wallet_reference": .reference(walletId), + ] + ) + return + } + let database = await handler.emitCoreWalletDatabaseDiagnostics( + walletId: walletId, + checkpoint: checkpoint + ) + guard let database else { return } + // The DB await above lets shutdown interleave. Admission is atomic on + // MainActor and keeps the copied handle alive across the off-main FFI + // work; shutdown drains this operation before consuming the handle. + guard isConfigured, handle != NULL_HANDLE else { + SDKLogger.event( + "core_memory_snapshot_unavailable", + category: .persistence, + severity: .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "reason": .publicText("manager_not_configured_after_database_snapshot"), + "wallet_reference": .reference(walletId), + ] + ) + return + } + do { + try admitCoreDiagnosticsNativeOp() + } catch { + SDKLogger.event( + "core_memory_snapshot_unavailable", + category: .persistence, + severity: .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "reason": .publicText("manager_shutdown_in_progress"), + "wallet_reference": .reference(walletId), + ] + ) + return + } + defer { finishCoreDiagnosticsNativeOp() } + + let managerHandle = handle + let managedWallet = wallets[walletId] + await withCheckedContinuation { continuation in + Self.destroyQueue.async { + Self.emitCoreMemoryDiagnostics( + managerHandle: managerHandle, + managedWallet: managedWallet, + database: database, + checkpoint: checkpoint + ) + continuation.resume() + } + } + } + + /// Blocking variant used only by the already-blocking synchronous restore + /// API. New application code should use the async public entry point. + func emitCoreWalletDiagnosticsSynchronously( + for walletId: Data, + checkpoint: CoreWalletDiagnosticCheckpoint + ) { + guard walletId.count == 32, + let handler = persistence, + let database = handler.emitCoreWalletDatabaseDiagnostics( + walletId: walletId, + checkpoint: checkpoint + ), + isConfigured, + handle != NULL_HANDLE + else { return } + Self.emitCoreMemoryDiagnostics( + managerHandle: handle, + managedWallet: wallets[walletId], + database: database, + checkpoint: checkpoint + ) + } + + private nonisolated static func emitCoreMemoryDiagnostics( + managerHandle: Handle, + managedWallet: ManagedPlatformWallet?, + database: CoreWalletDatabaseDiagnosticSnapshot, + checkpoint: CoreWalletDiagnosticCheckpoint + ) { + // Keep the two Rust-memory sources independent: corrupt account state + // must not suppress the AssetLock evidence that can explain a missing + // balance (and vice versa). + compareAssetLocks( + database, + managedWallet: managedWallet, + checkpoint: checkpoint + ) + let balanceQuery = diagnosticAccountBalances( + managerHandle: managerHandle, + walletId: database.walletId + ) + guard case .success(let balances) = balanceQuery else { + SDKLogger.event( + "core_memory_snapshot_unavailable", + category: .persistence, + severity: .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "reason": .publicText("account_balance_query_failed"), + "wallet_reference": .reference(database.walletId), + ] + ) + return + } + + var memoryTxos: [CoreWalletDatabaseDiagnosticSnapshot.Txo] = [] + var unavailableAccounts: Set = [] + let sortedBalances = balances.sorted { + Self.diagnosticAccountKey($0).referenceMaterial.lexicographicallyPrecedes( + Self.diagnosticAccountKey($1).referenceMaterial + ) + } + for balance in sortedBalances { + let key = Self.diagnosticAccountKey(balance) + let query = diagnosticAccountUtxos( + managerHandle: managerHandle, + walletId: database.walletId, + balance: balance + ) + guard case .success(let utxos) = query else { + unavailableAccounts.insert(key) + SDKLogger.event( + "core_memory_account_snapshot", + category: .persistence, + severity: .warning, + fields: [ + "account_reference": .reference(key.referenceMaterial), + "account_type": .unsignedInteger(UInt64(key.typeTag)), + "checkpoint": .publicText(checkpoint.rawValue), + "query_available": .boolean(false), + "wallet_reference": .reference(database.walletId), + ] + ) + continue + } + let materials = utxos.map { + diagnosticTxoFingerprint( + outpoint: $0.outpoint, + amount: $0.amount, + height: $0.height, + scriptPubKey: $0.scriptPubKey, + isLocked: $0.isLocked, + account: key + ) + } + SDKLogger.event( + "core_memory_account_snapshot", + category: .persistence, + fields: [ + "account_index": .unsignedInteger(UInt64(balance.index)), + "account_reference": .reference(key.referenceMaterial), + "account_type": .unsignedInteger(UInt64(balance.typeTag)), + "checkpoint": .publicText(checkpoint.rawValue), + "confirmed_duffs": .unsignedInteger(balance.confirmed), + "immature_duffs": .unsignedInteger(balance.immature), + "locked_duffs": .unsignedInteger(balance.locked), + "query_available": .boolean(true), + "standard_tag": .unsignedInteger(UInt64(balance.standardTag)), + "unconfirmed_duffs": .unsignedInteger(balance.unconfirmed), + "utxo_count": .integer(Int64(utxos.count)), + "utxo_fingerprint": .reference(diagnosticFingerprint(materials)), + "utxo_value_duffs": .unsignedInteger( + diagnosticSaturatingSum(utxos.map(\.amount)) + ), + "wallet_reference": .reference(database.walletId), + ] + ) + memoryTxos.append(contentsOf: utxos) + } + compareDatabase( + database, + memoryTxos: memoryTxos, + memoryAccounts: Set(balances.map(Self.diagnosticAccountKey)), + unavailableAccounts: unavailableAccounts, + checkpoint: checkpoint + ) + } + + private nonisolated static func compareDatabase( + _ database: CoreWalletDatabaseDiagnosticSnapshot, + memoryTxos: [CoreWalletDatabaseDiagnosticSnapshot.Txo], + memoryAccounts: Set, + unavailableAccounts: Set, + checkpoint: CoreWalletDiagnosticCheckpoint + ) { + let excludedDatabaseTxos = database.unspentTxos.filter { row in + row.account.map(unavailableAccounts.contains) ?? false + } + let comparableDatabaseTxos = database.unspentTxos.filter { row in + !(row.account.map(unavailableAccounts.contains) ?? false) + } + let result = CoreWalletDiagnosticAnalyzer.compareTxos( + database: comparableDatabaseTxos, + memory: memoryTxos, + databaseAccounts: Set(database.accounts), + memoryAccounts: memoryAccounts + ) + SDKLogger.event( + "core_db_memory_diff_summary", + category: .persistence, + severity: result.details.isEmpty + && result.databaseAccountOnlyCount == 0 + && result.memoryAccountOnlyCount == 0 + ? .info : .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "common_count": .integer(Int64(result.commonCount)), + "database_account_only_count": .integer( + Int64(result.databaseAccountOnlyCount) + ), + "database_only_count": .integer(Int64(result.databaseOnlyCount)), + "diff_incomplete": .boolean(!unavailableAccounts.isEmpty), + "excluded_database_txo_count": .integer(Int64(excludedDatabaseTxos.count)), + "field_mismatch_count": .integer(Int64(result.fieldMismatchCount)), + "memory_only_count": .integer(Int64(result.memoryOnlyCount)), + "memory_account_only_count": .integer(Int64(result.memoryAccountOnlyCount)), + "truncated_count": .integer(Int64(result.truncatedCount)), + "unavailable_account_count": .integer(Int64(unavailableAccounts.count)), + "wallet_reference": .reference(database.walletId), + ] + ) + for detail in result.emittedDetails { + logDiffItem( + database.walletId, + checkpoint, + detail.row, + detail.outpoint, + detail.reason + ) + } + } + + private nonisolated static func logDiffItem( + _ walletId: Data, + _ checkpoint: CoreWalletDiagnosticCheckpoint, + _ row: CoreWalletDatabaseDiagnosticSnapshot.Txo, + _ outpoint: Data, + _ reason: String + ) { + SDKLogger.event( + "core_db_memory_diff_item", + category: .persistence, + severity: .warning, + fields: [ + "amount_duffs": .unsignedInteger(row.amount), + "checkpoint": .publicText(checkpoint.rawValue), + "height": .unsignedInteger(UInt64(row.height)), + "outpoint_reference": .reference(outpoint), + "reason": .publicText(reason), + "wallet_reference": .reference(walletId), + ] + ) + } + + private nonisolated static func compareAssetLocks( + _ database: CoreWalletDatabaseDiagnosticSnapshot, + managedWallet: ManagedPlatformWallet?, + checkpoint: CoreWalletDiagnosticCheckpoint + ) { + let memory: [ManagedAssetLockManager.TrackedAssetLock] + do { + guard let managedWallet else { + throw PlatformWalletError.notFound("diagnostic wallet is not loaded") + } + memory = try managedWallet.assetLockManager().listTrackedLocks() + } catch { + SDKLogger.event( + "asset_lock_memory_snapshot", + category: .persistence, + severity: .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "query_available": .boolean(false), + "wallet_reference": .reference(database.walletId), + ] + ) + return + } + SDKLogger.event( + "asset_lock_memory_snapshot", + category: .persistence, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "lock_count": .integer(Int64(memory.count)), + "locked_value_duffs": .unsignedInteger( + diagnosticSaturatingSum(memory.map(\.amount)) + ), + "proof_present_count": .integer(Int64(memory.filter(\.hasProof).count)), + "query_available": .boolean(true), + "shielded_funding_count": .integer(Int64(memory.filter { + $0.fundingType == .assetLockShieldedAddressTopUp + }.count)), + "wallet_reference": .reference(database.walletId), + ] + ) + + let groups = Dictionary(grouping: memory) { + "\($0.fundingType.rawValue):\($0.status.rawValue)" + } + for key in groups.keys.sorted() { + guard let group = groups[key], let first = group.first else { continue } + SDKLogger.event( + "asset_lock_memory_group", + category: .persistence, + fields: [ + "amount_duffs": .unsignedInteger( + diagnosticSaturatingSum(group.map(\.amount)) + ), + "checkpoint": .publicText(checkpoint.rawValue), + "count": .integer(Int64(group.count)), + "funding_type": .unsignedInteger(UInt64(first.fundingType.rawValue)), + "proof_present_count": .integer(Int64(group.filter(\.hasProof).count)), + "status": .unsignedInteger(UInt64(first.status.rawValue)), + "wallet_reference": .reference(database.walletId), + ] + ) + } + + let normalizedMemory = memory.map { row in + CoreWalletDatabaseDiagnosticSnapshot.AssetLock( + outpointDisplay: Self.assetLockOutpointDisplay(txid: row.txid, vout: row.vout), + fundingType: Int(row.fundingType.rawValue), + status: Int(row.status.rawValue), + accountIndex: row.accountIndex, + registrationIndex: row.identityIndex, + amountDuffs: row.amount, + hasProof: row.hasProof + ) + } + guard database.assetLocksAvailable else { + SDKLogger.event( + "asset_lock_db_memory_diff_summary", + category: .persistence, + severity: .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "database_query_available": .boolean(false), + "diff_incomplete": .boolean(true), + "mismatch_count": .integer(0), + "truncated_count": .integer(0), + "wallet_reference": .reference(database.walletId), + ] + ) + return + } + let result = CoreWalletDiagnosticAnalyzer.compareAssetLocks( + database: database.assetLocks, + memory: normalizedMemory + ) + SDKLogger.event( + "asset_lock_db_memory_diff_summary", + category: .persistence, + severity: result.details.isEmpty ? .info : .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "database_query_available": .boolean(true), + "diff_incomplete": .boolean(false), + "mismatch_count": .integer(Int64(result.details.count)), + "truncated_count": .integer(Int64(result.truncatedCount)), + "wallet_reference": .reference(database.walletId), + ] + ) + for detail in result.emittedDetails { + SDKLogger.event( + "asset_lock_db_memory_diff_item", + category: .persistence, + severity: .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "outpoint_reference": .referenceString(detail.outpointDisplay), + "reason": .publicText(detail.reason), + "wallet_reference": .reference(database.walletId), + ] + ) + } + } + + private nonisolated static func diagnosticAccountBalances( + managerHandle: Handle, + walletId: Data + ) -> Result<[AccountBalance], PlatformWalletError> { + var outEntries: UnsafePointer? + var outCount: UInt = 0 + let ffi = walletId.withUnsafeBytes { raw in + platform_wallet_manager_get_account_balances( + managerHandle, + raw.baseAddress?.assumingMemoryBound(to: UInt8.self), + &outEntries, + &outCount + ) + } + let result = PlatformWalletResult(ffi) + guard result.isSuccess else { return .failure(PlatformWalletError(result: result)) } + guard let entries = outEntries, outCount > 0 else { return .success([]) } + defer { + platform_wallet_manager_free_account_balances( + UnsafeMutablePointer(mutating: entries), outCount + ) + } + return .success((0.. Result<[CoreWalletDatabaseDiagnosticSnapshot.Txo], PlatformWalletError> { + var spec = AccountSpecFFI() + spec.type_tag = balance.typeTag + spec.standard_tag = balance.standardTag + spec.index = balance.index + spec.registration_index = balance.registrationIndex + spec.key_class = balance.keyClass + _ = Swift.withUnsafeMutableBytes(of: &spec.user_identity_id) { raw in + balance.userIdentityId.copyBytes( + to: raw.bindMemory(to: UInt8.self), + count: min(32, balance.userIdentityId.count) + ) + } + _ = Swift.withUnsafeMutableBytes(of: &spec.friend_identity_id) { raw in + balance.friendIdentityId.copyBytes( + to: raw.bindMemory(to: UInt8.self), + count: min(32, balance.friendIdentityId.count) + ) + } + var outEntries: UnsafePointer? + var outCount: UInt = 0 + let ffi = walletId.withUnsafeBytes { raw in + platform_wallet_account_utxos( + managerHandle, + raw.baseAddress?.assumingMemoryBound(to: UInt8.self), + &spec, + &outEntries, + &outCount + ) + } + let result = PlatformWalletResult(ffi) + guard result.isSuccess else { return .failure(PlatformWalletError(result: result)) } + guard let entries = outEntries, outCount > 0 else { return .success([]) } + defer { + platform_wallet_account_utxos_free( + UnsafeMutablePointer(mutating: entries), outCount + ) + } + let key = Self.diagnosticAccountKey(balance) + return .success((0.. CoreWalletDatabaseDiagnosticSnapshot.AccountKey { + CoreWalletDatabaseDiagnosticSnapshot.AccountKey( + typeTag: UInt32(balance.typeTag), + standardTag: balance.standardTag, + index: balance.index, + registrationIndex: balance.registrationIndex, + keyClass: balance.keyClass, + userIdentityId: balance.userIdentityId, + friendIdentityId: balance.friendIdentityId + ) + } + + private nonisolated static func assetLockOutpointDisplay( + txid: Data, + vout: UInt32 + ) -> String { + let display = txid.reversed().map { String(format: "%02x", $0) }.joined() + return "\(display):\(vout)" + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swift index 88670cb1f2c..b38f4a0a37b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swift @@ -91,6 +91,24 @@ public struct PlatformSpvSyncProgress: Sendable, Equatable { } } +enum CoreRescanDiagnosticResult: String, Sendable, Equatable { + case armed + case acceptedNoRewind = "accepted_no_rewind" + case noOp = "no_op" +} + +/// Classifies only what can be proven from the checkpoint visible before the +/// accepted FFI call. A missing checkpoint is not evidence of a rewind. +func coreRescanDiagnosticResult( + previousSyncedHeight: UInt32?, + requestedStartHeight: UInt32 +) -> CoreRescanDiagnosticResult { + guard let previousSyncedHeight else { return .acceptedNoRewind } + if requestedStartHeight < previousSyncedHeight { return .armed } + if requestedStartHeight == previousSyncedHeight { return .noOp } + return .acceptedNoRewind +} + /// Node type of a connected SPV peer, classified against the masternode /// list. Mirrors Rust's `SpvPeerNodeType` / the `SPV_PEER_NODE_TYPE_*` /// FFI constants. @@ -316,12 +334,48 @@ extension PlatformWalletManager { "walletId must be exactly 32 bytes" ) } - try walletId.withUnsafeBytes { widRaw in - guard let widPtr = widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self) - else { - throw PlatformWalletError.invalidParameter("walletId baseAddress is nil") + let previousHeight = coreWalletState(for: walletId)?.syncedHeight + do { + try walletId.withUnsafeBytes { widRaw in + guard let widPtr = widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self) + else { + throw PlatformWalletError.invalidParameter("walletId baseAddress is nil") + } + try platform_wallet_manager_spv_rescan_filters(handle, widPtr, fromHeight).check() } - try platform_wallet_manager_spv_rescan_filters(handle, widPtr, fromHeight).check() + let diagnosticResult = coreRescanDiagnosticResult( + previousSyncedHeight: previousHeight, + requestedStartHeight: fromHeight + ) + var fields: [String: SDKLogValue] = [ + "from_height": .unsignedInteger(UInt64(fromHeight)), + "result": .publicText(diagnosticResult.rawValue), + "wallet_reference": .reference(walletId), + ] + if let previousHeight { + fields["previous_synced_height"] = .unsignedInteger(UInt64(previousHeight)) + } + SDKLogger.event( + "core_rescan_armed", + category: .persistence, + fields: fields + ) + } catch { + var fields: [String: SDKLogValue] = [ + "from_height": .unsignedInteger(UInt64(fromHeight)), + "result": .publicText("failed"), + "wallet_reference": .reference(walletId), + ] + if let previousHeight { + fields["previous_synced_height"] = .unsignedInteger(UInt64(previousHeight)) + } + SDKLogger.event( + "core_rescan_armed", + category: .persistence, + severity: .error, + fields: fields + ) + throw error } } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 4c0baa95899..9d0fb6731b3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -117,7 +117,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// `serialQueue`: every public entry point wraps its body in /// `onQueue { … }`, and internal helpers (`upsertTransaction`, /// `markUtxoSpent`, …) assume they are already on the queue. - private let backgroundContext: ModelContext + /// Internal only so the read-only diagnostics extension can take its + /// snapshot on the same serialized context as the persistence callbacks. + /// Production persistence code must continue to enter through `onQueue`. + let backgroundContext: ModelContext /// Taken instead of `backgroundContext.fetch` by the reads whose /// failure must reject the round (see `ModelFetching`). @@ -135,7 +138,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// entry points — both the FFI callback shims and the /// app-facing accessors — funnel through `onQueue` so the /// context is only ever touched on this queue. - private let serialQueue = DispatchQueue( + /// Internal only so diagnostics can enqueue an asynchronous, read-only + /// snapshot without blocking the main actor. All mutations remain in this + /// file's persistence callbacks. + let serialQueue = DispatchQueue( label: "org.dash.platform-wallet.persistence", qos: .userInitiated ) @@ -227,12 +233,25 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// /// The pool goes inside the `sync` so it wraps exactly one unit of work /// and is drained before the Rust caller is resumed. - private func onQueue(_ body: () throws -> T) rethrows -> T { + /// Internal only for the read-only diagnostics extension. Keeping the + /// diagnostic reads on this queue gives each exported snapshot a coherent + /// view and prevents it racing an in-flight Rust changeset save. + func onQueue(_ body: () throws -> T) rethrows -> T { try serialQueue.sync { try autoreleasepool { try body() } } } + /// Clears pre-restore diagnostic values that were not consumed by a + /// successful post-restore comparison. Safe to call from manager failure + /// and skipped-wallet paths; do not call recursively while `serialQueue` + /// is already held. + func clearStartupCoreDiagnosticSnapshots() { + onQueue { + startupCoreDiagnosticSnapshots.removeAll(keepingCapacity: true) + } + } + /// Best-effort save used by callback helpers that may also be invoked /// outside a Rust changeset. The legacy behavior remains non-throwing, /// but failures are no longer invisible in exported diagnostics. @@ -5175,6 +5194,16 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ] ) return onQueue { + // Start every bulk attempt from an empty cache. Retain the snapshots + // only when the complete FFI buffer is handed back successfully; + // every validation/fetch/allocation failure exits through this defer. + startupCoreDiagnosticSnapshots.removeAll(keepingCapacity: true) + var preserveStartupDiagnosticSnapshots = false + defer { + if !preserveStartupDiagnosticSnapshots { + startupCoreDiagnosticSnapshots.removeAll(keepingCapacity: true) + } + } healIdentityIsLocalFlags() // Scope the fetch to the handler's bound network so a // per-network manager only sees its own wallets. If @@ -5222,6 +5251,17 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return (nil, 0, false) } + // Capture the durable source-of-truth before any bytes cross the FFI + // boundary. We are already on `serialQueue`, so call the on-queue + // implementation directly (the public wrapper would deadlock by + // recursively entering `serialQueue.sync`). + for wallet in restorable { + emitCoreWalletDatabaseDiagnosticsOnQueue( + walletId: wallet.walletId, + checkpoint: .startupPreRestore + ) + } + // Single bucketed fetch of every unspent `PersistentTxo` so // each wallet's per-iteration buffer build is a dictionary // lookup instead of a fresh database round-trip. Prefetches @@ -5345,6 +5385,11 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { guard row.account != nil else { continue } let key: Data if !row.walletId.isEmpty { + // Keep a denorm-scoped row even when its account + // relationship is missing. `buildUtxoRestoreBuffer` + // still skips it exactly as before, while the adjacent + // diagnostic summary can now report the rejection instead + // of silently losing the evidence. key = row.walletId } else if let account = row.account { // `account.wallet` is non-optional on the @@ -5581,6 +5626,12 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { rows: unspentBuckets[w.walletId] ?? [], allocation: allocation ) + logCoreRestoreBufferSnapshotOnQueue( + walletId: w.walletId, + rows: unspentBuckets[w.walletId] ?? [], + emittedCount: utxoCount, + errored: utxoErrored + ) // `buildUtxoRestoreBuffer` already deallocated its own // buffer on the errored path; release everything else // we've accumulated and abort the load callback so Rust @@ -5672,6 +5723,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { category: .persistence, fields: ["wallet_count": .integer(Int64(restorable.count))] ) + preserveStartupDiagnosticSnapshots = true return (typed, restorable.count, false) } // onQueue } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticAnalyzerTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticAnalyzerTests.swift new file mode 100644 index 00000000000..70e4a88d6be --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticAnalyzerTests.swift @@ -0,0 +1,380 @@ +import Foundation +import XCTest +@testable import SwiftDashSDK + +final class CoreWalletDiagnosticAnalyzerTests: XCTestCase { + typealias AccountKey = CoreWalletDatabaseDiagnosticSnapshot.AccountKey + typealias AssetLock = CoreWalletDatabaseDiagnosticSnapshot.AssetLock + typealias Txo = CoreWalletDatabaseDiagnosticSnapshot.Txo + + private func account( + type: UInt32 = 0, + standardTag: UInt8 = 0, + index: UInt32 = 0 + ) -> AccountKey { + AccountKey( + typeTag: type, + standardTag: standardTag, + index: index, + registrationIndex: 0, + keyClass: 0, + userIdentityId: Data(), + friendIdentityId: Data() + ) + } + + private func outpoint(_ marker: UInt8) -> Data { + Data(repeating: marker, count: 32) + Data([0, 0, 0, 0]) + } + + private func txo( + _ marker: UInt8, + amount: UInt64 = 100, + height: UInt32 = 200, + script: Data = Data([0x51]), + locked: Bool = false, + account: AccountKey? = nil + ) -> Txo { + Txo( + outpoint: outpoint(marker), + amount: amount, + height: height, + scriptPubKey: script, + isLocked: locked, + account: account ?? self.account() + ) + } + + private func assetLock( + _ outpoint: String, + fundingType: Int = 5, + status: Int = 1, + accountIndex: UInt32 = 2, + registrationIndex: UInt32 = 3, + amount: UInt64? = 400, + hasProof: Bool = true + ) -> AssetLock { + AssetLock( + outpointDisplay: outpoint, + fundingType: fundingType, + status: status, + accountIndex: accountIndex, + registrationIndex: registrationIndex, + amountDuffs: amount, + hasProof: hasProof + ) + } + + func testTxoDiffExactDatabaseOnlyMemoryOnlyAndEveryFieldMismatch() { + let baseAccount = account() + let exact = txo(0x01, account: baseAccount) + let exactResult = CoreWalletDiagnosticAnalyzer.compareTxos( + database: [exact], + memory: [exact], + databaseAccounts: [baseAccount], + memoryAccounts: [baseAccount] + ) + XCTAssertEqual(exactResult.commonCount, 1) + XCTAssertEqual(exactResult.databaseAccountOnlyCount, 0) + XCTAssertEqual(exactResult.memoryAccountOnlyCount, 0) + XCTAssertTrue(exactResult.details.isEmpty) + + let database = [ + txo(0x10), + txo(0x20, amount: 101), + txo(0x21, height: 201), + txo(0x22, script: Data([0x52])), + txo(0x23, locked: true), + txo(0x24, account: account(type: 1)), + ] + let memory = [ + txo(0x11), + txo(0x20, amount: 102), + txo(0x21, height: 202), + txo(0x22, script: Data([0x53])), + txo(0x23, locked: false), + txo(0x24, account: account(type: 0)), + ] + let result = CoreWalletDiagnosticAnalyzer.compareTxos( + database: database, + memory: memory, + databaseAccounts: [baseAccount, account(type: 1)], + memoryAccounts: [baseAccount, account(type: 2)] + ) + + XCTAssertEqual(result.commonCount, 5) + XCTAssertEqual(result.databaseAccountOnlyCount, 1) + XCTAssertEqual(result.memoryAccountOnlyCount, 1) + XCTAssertEqual(result.databaseOnlyCount, 1) + XCTAssertEqual(result.memoryOnlyCount, 1) + XCTAssertEqual(result.fieldMismatchCount, 5) + XCTAssertEqual(Set(result.details.map(\.reason)), [ + "account_mismatch", + "amount_mismatch", + "database_only", + "height_mismatch", + "lock_mismatch", + "memory_only", + "script_mismatch", + ]) + } + + func testTxoDiffLimitsEachReasonToTwentyFiveDetails() { + let database = (0..<30).map { index in + txo(UInt8(index + 1)) + } + let result = CoreWalletDiagnosticAnalyzer.compareTxos( + database: database, + memory: [], + databaseAccounts: [], + memoryAccounts: [] + ) + + XCTAssertEqual(result.details.count, 30) + XCTAssertEqual(result.emittedDetails.count, 25) + XCTAssertEqual(result.truncatedCount, 5) + XCTAssertTrue(result.emittedDetails.allSatisfy { $0.reason == "database_only" }) + XCTAssertEqual( + result.emittedDetails.map(\.outpoint), + result.emittedDetails.map(\.outpoint).sorted { + $0.lexicographicallyPrecedes($1) + } + ) + } + + func testAssetLockDiffExactAndEveryMismatchClass() { + let exact = assetLock("exact:0") + let exactResult = CoreWalletDiagnosticAnalyzer.compareAssetLocks( + database: [exact], + memory: [exact] + ) + XCTAssertTrue(exactResult.details.isEmpty) + + let result = CoreWalletDiagnosticAnalyzer.compareAssetLocks( + database: [ + assetLock("database-only:0"), + assetLock("different:0"), + ], + memory: [ + assetLock("memory-only:0"), + assetLock( + "different:0", + fundingType: 4, + status: 2, + accountIndex: 7, + registrationIndex: 8, + amount: 401, + hasProof: false + ), + ] + ) + + XCTAssertEqual(Set(result.details.map(\.reason)), [ + "account_index_mismatch", + "amount_mismatch", + "database_only", + "funding_type_mismatch", + "memory_only", + "proof_presence_mismatch", + "registration_index_mismatch", + "status_mismatch", + ]) + XCTAssertEqual(result.emittedDetails.count, result.details.count) + XCTAssertEqual(result.truncatedCount, 0) + } + + func testMissingAccountIsDatabaseAnomalyAndRejectedFromRestoreBuffer() { + let missingAccountTxo = Txo( + outpoint: outpoint(0x30), + amount: 700, + height: 900, + scriptPubKey: Data([0x51]), + isLocked: false, + account: nil + ) + let anomalies = CoreWalletDiagnosticAnalyzer.databaseTxoAnomalies([ + .init( + txo: missingAccountTxo, + hasParentTransaction: true, + walletIdMismatch: false, + isSpent: false, + hasSpendingTransaction: false + ), + ]) + XCTAssertEqual(anomalies.count(reason: "missing_account"), 1) + + let rejected = CoreWalletDiagnosticAnalyzer.RestoreCandidate( + txo: missingAccountTxo, + accountType: nil, + standardTag: nil, + rejectionReason: .missingAccount, + isCoinbase: false, + isConfirmed: true, + isInstantLocked: false + ) + let acceptedTxo = txo(0x31, amount: 800) + let accepted = CoreWalletDiagnosticAnalyzer.RestoreCandidate( + txo: acceptedTxo, + accountType: 0, + standardTag: 0, + rejectionReason: nil, + isCoinbase: false, + isConfirmed: true, + isInstantLocked: false + ) + let summary = CoreWalletDiagnosticAnalyzer.summarizeRestoreBuffer( + candidates: [rejected, accepted], + emittedCount: 1, + errored: false + ) + + XCTAssertEqual(summary.candidateCount, 2) + XCTAssertEqual(summary.candidateValueDuffs, 1_500) + XCTAssertEqual(summary.missingAccountCount, 1) + XCTAssertEqual(summary.emittedCandidates.count, 1) + XCTAssertEqual(summary.emittedCandidates.first?.txo.outpoint, acceptedTxo.outpoint) + XCTAssertEqual(summary.emittedValueDuffs, 800) + } + + func testShieldedStoreSummaryIncludesValuesActivityKeysAndWatermark() { + let summary = CoreWalletDiagnosticAnalyzer.summarizeShieldedStore( + notes: [ + .init(value: 7, isSpent: true), + .init(value: 8, isSpent: true), + .init(value: 20, isSpent: false), + ], + outgoingNoteCount: 2, + activityStatuses: [0, 1, 2, 0], + viewingKeyCount: 3, + syncWatermarks: [5, 99, 40] + ) + + XCTAssertEqual(summary.noteCount, 3) + XCTAssertEqual(summary.spentNoteCount, 2) + XCTAssertEqual(summary.spentValueCredits, 15) + XCTAssertEqual(summary.unspentNoteCount, 1) + XCTAssertEqual(summary.unspentValueCredits, 20) + XCTAssertEqual(summary.outgoingNoteCount, 2) + XCTAssertEqual(summary.activityCount, 4) + XCTAssertEqual(summary.activityPendingCount, 2) + XCTAssertEqual(summary.activityFailedCount, 1) + XCTAssertEqual(summary.viewingKeyCount, 3) + XCTAssertEqual(summary.subwalletSyncStateCount, 3) + XCTAssertEqual(summary.maximumSyncWatermark, 99) + } + + func testFingerprintIsStableUnderReorderAndSensitiveToEveryTxoField() { + let baseAccount = account() + let first = txo(0x40, account: baseAccount) + let second = txo(0x41, amount: 200, account: baseAccount) + let firstMaterial = fingerprintMaterial(first) + let secondMaterial = fingerprintMaterial(second) + + XCTAssertEqual( + diagnosticFingerprint([firstMaterial, secondMaterial]), + diagnosticFingerprint([secondMaterial, firstMaterial]) + ) + XCTAssertNotEqual(firstMaterial, fingerprintMaterial(txo(0x40, amount: 101))) + XCTAssertNotEqual(firstMaterial, fingerprintMaterial(txo(0x40, height: 201))) + XCTAssertNotEqual(firstMaterial, fingerprintMaterial(txo(0x40, script: Data([0x52])))) + XCTAssertNotEqual(firstMaterial, fingerprintMaterial(txo(0x40, locked: true))) + XCTAssertNotEqual( + firstMaterial, + fingerprintMaterial(txo(0x40, account: account(type: 1))) + ) + } + + func testRestoreFingerprintIncludesEveryRestoreOnlyFlag() { + let base = CoreWalletDiagnosticAnalyzer.RestoreCandidate( + txo: txo(0x50), + accountType: 0, + standardTag: 0, + rejectionReason: nil, + isCoinbase: false, + isConfirmed: false, + isInstantLocked: false + ) + let coinbase = CoreWalletDiagnosticAnalyzer.RestoreCandidate( + txo: base.txo, + accountType: base.accountType, + standardTag: base.standardTag, + rejectionReason: nil, + isCoinbase: true, + isConfirmed: false, + isInstantLocked: false + ) + let confirmed = CoreWalletDiagnosticAnalyzer.RestoreCandidate( + txo: base.txo, + accountType: base.accountType, + standardTag: base.standardTag, + rejectionReason: nil, + isCoinbase: false, + isConfirmed: true, + isInstantLocked: false + ) + let instantLocked = CoreWalletDiagnosticAnalyzer.RestoreCandidate( + txo: base.txo, + accountType: base.accountType, + standardTag: base.standardTag, + rejectionReason: nil, + isCoinbase: false, + isConfirmed: false, + isInstantLocked: true + ) + + XCTAssertNotEqual( + diagnosticRestoreTxoFingerprint(base), + diagnosticRestoreTxoFingerprint(coinbase) + ) + XCTAssertNotEqual( + diagnosticRestoreTxoFingerprint(base), + diagnosticRestoreTxoFingerprint(confirmed) + ) + XCTAssertNotEqual( + diagnosticRestoreTxoFingerprint(base), + diagnosticRestoreTxoFingerprint(instantLocked) + ) + } + + func testRescanDiagnosticResultOnlyReportsArmedForARealRewind() { + XCTAssertEqual( + coreRescanDiagnosticResult( + previousSyncedHeight: 2_500_000, + requestedStartHeight: 2_484_000 + ), + .armed + ) + XCTAssertEqual( + coreRescanDiagnosticResult( + previousSyncedHeight: 2_484_000, + requestedStartHeight: 2_484_000 + ), + .noOp + ) + XCTAssertEqual( + coreRescanDiagnosticResult( + previousSyncedHeight: 2_480_000, + requestedStartHeight: 2_484_000 + ), + .acceptedNoRewind + ) + XCTAssertEqual( + coreRescanDiagnosticResult( + previousSyncedHeight: nil, + requestedStartHeight: 2_484_000 + ), + .acceptedNoRewind + ) + } + + private func fingerprintMaterial(_ txo: Txo) -> Data { + diagnosticTxoFingerprint( + outpoint: txo.outpoint, + amount: txo.amount, + height: txo.height, + scriptPubKey: txo.scriptPubKey, + isLocked: txo.isLocked, + account: txo.account + ) + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticsTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticsTests.swift new file mode 100644 index 00000000000..424b714c140 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticsTests.swift @@ -0,0 +1,300 @@ +import Foundation +import SwiftData +import XCTest +@testable import SwiftDashSDK + +/// Regression coverage for the diagnostic that identifies #4438: a sent +/// transaction consumes a CoinJoin output and pays change back to an address +/// owned by the wallet's BIP44 account, but the owned output is absent from +/// SwiftData. The same test exercises the complete structured-log line so a +/// future field addition cannot accidentally expose wallet material. +@MainActor +final class CoreWalletDiagnosticsTests: XCTestCase { + private static let fixtureHex = + "01000000011111111111111111111111111111111111111111111111111111111111111111" + + "030000006a4730303030303030303030303030303030303030303030303030303030303030" + + "30303030303030303030303030303030303030303030303030303030303030303030303030" + + "303030210324653eac434488002cc06bbfb7f10fe18991e35f9fe4302dbea6d2353dc0ab1c" + + "ffffffff02204e0200000000001976a91414db4138d56a2ecfb10881a9be394d9f321985b2" + + "88ac0000000000000000066a04aaaaaaaa00000000" + + private static let fixtureAddress = "yNDj28QBMm5sY6bLjFcNdWRNef24KLQNuQ" + private static let fixtureTxidDisplay = + "bf7479216e5ba76f60bf11654c881824c6f9cdbb64eebe332cf835a3391cb5d5" + + private let walletId = Data(repeating: 0xa1, count: 32) + + private var fixtureData: Data { + var data = Data() + var index = Self.fixtureHex.startIndex + while index < Self.fixtureHex.endIndex { + let next = Self.fixtureHex.index(index, offsetBy: 2) + data.append(UInt8(Self.fixtureHex[index.. URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "CoreWalletDiagnosticsTests-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: directory) } + return directory + } + + private func logLines(in session: URL, event: String) throws -> [String] { + SDKLogger.flush() + let log = try String( + contentsOf: session.appendingPathComponent("swift/run.log"), + encoding: .utf8 + ) + return log.split(separator: "\n").map(String.init).filter { + $0.contains("event=\(event) ") + } + } + + private struct Fixture { + let handler: PlatformWalletPersistenceHandler + let context: ModelContext + let spendingTransaction: PersistentTransaction + let bip44Account: PersistentAccount + let bip44Address: PersistentCoreAddress + let decoded: DecodedTransaction + } + + private func makeMissingOwnedOutputFixture() throws -> Fixture { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + context.autosaveEnabled = false + let handler = PlatformWalletPersistenceHandler( + modelContainer: container, + network: .testnet + ) + + let wallet = PersistentWallet(walletId: walletId, network: .testnet) + context.insert(wallet) + + let bip44 = PersistentAccount( + wallet: wallet, + accountType: 0, + accountIndex: 0, + accountTypeName: "Standard" + ) + bip44.standardTag = 0 + context.insert(bip44) + + let coinJoin = PersistentAccount( + wallet: wallet, + accountType: 1, + accountIndex: 0, + accountTypeName: "CoinJoin" + ) + context.insert(coinJoin) + + let address = PersistentCoreAddress( + address: Self.fixtureAddress, + poolTypeTag: 1, + addressIndex: 4, + derivationPath: "privacy-fixture-path" + ) + address.account = bip44 + context.insert(address) + + // This is the output that the decoded fixture spends (11…11:3). + // Empty consensus bytes keep it out of the transaction decoder while + // preserving the real ownership relation used by the audit. + let funding = PersistentTransaction( + txid: Data(repeating: 0x11, count: 32), + transactionData: Data(), + context: 2, + blockHeight: 100, + netAmount: 151_072 + ) + context.insert(funding) + let coinJoinTxo = PersistentTxo( + transaction: funding, + vout: 3, + amount: 151_072, + address: "coinjoin-input-address", + scriptPubKey: Data([0x51]), + height: 100 + ) + coinJoinTxo.account = coinJoin + coinJoinTxo.walletId = walletId + coinJoinTxo.isConfirmed = true + context.insert(coinJoinTxo) + + let decoded = try TransactionDecoder.decode(fixtureData, network: .testnet) + let spending = PersistentTransaction( + txid: decoded.txid, + transactionData: fixtureData, + context: 2, + blockHeight: 101, + direction: 1, + netAmount: -151_072 + ) + spending.involvedAccounts.append(coinJoin) + coinJoinTxo.spendingTransaction = spending + coinJoinTxo.isSpent = true + context.insert(spending) + + try context.save() + return Fixture( + handler: handler, + context: context, + spendingTransaction: spending, + bip44Account: bip44, + bip44Address: address, + decoded: decoded + ) + } + + func testCoinJoinSpendWithMissingBip44ChangeDetects4438AndLogIsPrivate() throws { + let fixture = try makeMissingOwnedOutputFixture() + let session = try temporaryDirectory() + XCTAssertTrue(SDKLogger.installFileSink(at: session, includeDebug: false)) + + XCTAssertNotNil(fixture.handler.emitCoreWalletDatabaseDiagnostics( + walletId: walletId, + checkpoint: .preExport + )) + + let summaries = try logLines(in: session, event: "core_owned_output_audit_summary") + let summary = try XCTUnwrap(summaries.last) + XCTAssertTrue(summary.contains("candidate_transaction_count=1"), summary) + XCTAssertTrue(summary.contains("coinjoin_to_bip44_missing_count=1"), summary) + XCTAssertTrue(summary.contains("coinjoin_to_bip44_missing_value_duffs=151072"), summary) + XCTAssertTrue(summary.contains("owned_bip44_output_count=1"), summary) + XCTAssertTrue(summary.contains("persisted_valid_count=0"), summary) + XCTAssertTrue(summary.contains("total_anomaly_count=1"), summary) + + let anomalies = try logLines(in: session, event: "core_owned_output_anomaly") + let anomaly = try XCTUnwrap(anomalies.last) + XCTAssertTrue(anomaly.contains(#"reason="missing_txo""#), anomaly) + + // Assert privacy over every line generated by the complete snapshot, + // not just over one hand-constructed formatter input. + SDKLogger.flush() + let completeLog = try String( + contentsOf: session.appendingPathComponent("swift/run.log"), + encoding: .utf8 + ) + XCTAssertFalse(completeLog.contains(Self.fixtureAddress)) + XCTAssertFalse(completeLog.contains(Self.fixtureTxidDisplay)) + XCTAssertFalse(completeLog.contains(Self.fixtureHex)) + XCTAssertFalse(completeLog.contains("privacy-fixture-path")) + let rawTxidHex = fixture.decoded.txid.map { String(format: "%02x", $0) }.joined() + let reversedTxidHex = fixture.decoded.txid.reversed().map { + String(format: "%02x", $0) + }.joined() + let scriptHex = fixture.decoded.outputs[0].scriptPubkey.map { + String(format: "%02x", $0) + }.joined() + let rawOutpointHex = PersistentTxo.makeOutpoint( + txid: fixture.decoded.txid, + vout: 0 + ).map { String(format: "%02x", $0) }.joined() + XCTAssertFalse(completeLog.contains(rawTxidHex)) + XCTAssertFalse(completeLog.contains(reversedTxidHex)) + XCTAssertFalse(completeLog.contains(scriptHex)) + XCTAssertFalse(completeLog.contains(rawOutpointHex)) + XCTAssertFalse(completeLog.contains(walletId.map { String(format: "%02x", $0) }.joined())) + XCTAssertFalse(completeLog.contains(Data(repeating: 0x11, count: 32).map { + String(format: "%02x", $0) + }.joined())) + } + + func testPersistedBip44ChangeClears4438Alarm() throws { + let fixture = try makeMissingOwnedOutputFixture() + let output = fixture.decoded.outputs[0] + let change = PersistentTxo( + transaction: fixture.spendingTransaction, + vout: 0, + amount: output.valueDuffs, + address: try XCTUnwrap(output.address), + scriptPubKey: output.scriptPubkey, + height: fixture.spendingTransaction.blockHeight + ) + change.account = fixture.bip44Account + change.coreAddress = fixture.bip44Address + change.walletId = walletId + change.isConfirmed = true + fixture.context.insert(change) + try fixture.context.save() + + let session = try temporaryDirectory() + XCTAssertTrue(SDKLogger.installFileSink(at: session, includeDebug: false)) + XCTAssertNotNil(fixture.handler.emitCoreWalletDatabaseDiagnostics( + walletId: walletId, + checkpoint: .preExport + )) + + let summaries = try logLines(in: session, event: "core_owned_output_audit_summary") + let summary = try XCTUnwrap(summaries.last) + XCTAssertTrue(summary.contains("coinjoin_to_bip44_missing_count=0"), summary) + XCTAssertTrue(summary.contains("owned_bip44_output_count=1"), summary) + XCTAssertTrue(summary.contains("persisted_valid_count=1"), summary) + XCTAssertTrue(summary.contains("total_anomaly_count=0"), summary) + XCTAssertTrue(try logLines(in: session, event: "core_owned_output_anomaly").isEmpty) + } + + func testStartupPreRestoreClearsStaleSnapshotBeforeAFailedRefresh() throws { + let container = try DashModelContainer.createInMemory() + let handler = PlatformWalletPersistenceHandler( + modelContainer: container, + network: .testnet + ) + let stale = CoreWalletDatabaseDiagnosticSnapshot( + walletId: walletId, + accounts: [], + unspentTxos: [], + assetLocks: [], + assetLocksAvailable: true + ) + + handler.onQueue { + handler.startupCoreDiagnosticSnapshots[walletId] = stale + XCTAssertNil(handler.emitCoreWalletDatabaseDiagnosticsOnQueue( + walletId: walletId, + checkpoint: .startupPreRestore + )) + XCTAssertNil(handler.startupCoreDiagnosticSnapshots[walletId]) + } + } + + func testStartupCacheClearDropsEveryUnconsumedSnapshot() throws { + let container = try DashModelContainer.createInMemory() + let handler = PlatformWalletPersistenceHandler( + modelContainer: container, + network: .testnet + ) + let first = CoreWalletDatabaseDiagnosticSnapshot( + walletId: walletId, + accounts: [], + unspentTxos: [], + assetLocks: [], + assetLocksAvailable: true + ) + let secondId = Data(repeating: 0xb2, count: 32) + let second = CoreWalletDatabaseDiagnosticSnapshot( + walletId: secondId, + accounts: [], + unspentTxos: [], + assetLocks: [], + assetLocksAvailable: true + ) + handler.onQueue { + handler.startupCoreDiagnosticSnapshots[walletId] = first + handler.startupCoreDiagnosticSnapshots[secondId] = second + } + + handler.clearStartupCoreDiagnosticSnapshots() + + handler.onQueue { + XCTAssertTrue(handler.startupCoreDiagnosticSnapshots.isEmpty) + } + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift new file mode 100644 index 00000000000..978f4980349 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift @@ -0,0 +1,75 @@ +import Foundation +import SwiftData +import XCTest + +@testable import SwiftDashSDK + +/// Pins the store-opening semantics used by DashWallet's +/// `SwiftDashSDKHost.buildModelContainer`: the current schema with inferred +/// lightweight migration and no staged migration plan. +/// +/// `DashModelContainer.create` currently supplies `DashMigrationPlan` and +/// rejects the real v4.2.0-dev.1 checksum with Cocoa error 134504 because the +/// historical `PersistentDocumentType` and `PersistentIndex` shapes are not +/// registered as a frozen schema. This test deliberately does not exercise +/// that known-broken factory path; it verifies that the app-compatible path +/// opens the old store and preserves its Core wallet records. +@MainActor +final class Dev1StoreUpgradeTests: XCTestCase { + func testDev1StoreOpensWithoutStagedPlanAndPreservesCoreRows() throws { + let resourceURL = try XCTUnwrap( + Bundle.module.url( + forResource: "DashModel-v4.2.0-dev.1.sqlite", + withExtension: "zlib", + subdirectory: "Fixtures" + ) + ) + let compressed = try Data(contentsOf: resourceURL) + // This resource is produced with Foundation's `.zlib` compressor. + // A Python zlib-wrapped stream is not accepted by NSData on iOS. + let sqlite = try (compressed as NSData).decompressed(using: .zlib) as Data + XCTAssertEqual(sqlite.count, 647_168) + + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + addTeardownBlock { + try? FileManager.default.removeItem(at: directory) + } + + let storeURL = directory.appendingPathComponent("DashModel.sqlite") + try sqlite.write(to: storeURL, options: .atomic) + + let schema = DashModelContainer.schema + let configuration = ModelConfiguration( + schema: schema, + url: storeURL, + allowsSave: true, + cloudKitDatabase: .none + ) + let container = try ModelContainer( + for: schema, + configurations: [configuration] + ) + let context = ModelContext(container) + + let wallets = try context.fetch(FetchDescriptor()) + let accounts = try context.fetch(FetchDescriptor()) + + XCTAssertEqual(wallets.count, 1) + XCTAssertEqual(accounts.count, 1) + XCTAssertEqual(wallets[0].walletId, Data(repeating: 0xA1, count: 32)) + XCTAssertEqual(wallets[0].birthHeight, 2_400_000) + XCTAssertEqual(wallets[0].syncedHeight, 2_500_000) + XCTAssertEqual(accounts[0].accountType, 0) + XCTAssertEqual(accounts[0].accountIndex, 0) + XCTAssertEqual( + accounts[0].accountExtendedPubKeyBytes, + Data(repeating: 0x02, count: 78) + ) + XCTAssertEqual(accounts[0].wallet.walletId, wallets[0].walletId) + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/DashModel-v4.2.0-dev.1.sqlite.zlib b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/DashModel-v4.2.0-dev.1.sqlite.zlib new file mode 100644 index 0000000000000000000000000000000000000000..836d5beeb5a42eaf7579d4bf41067167b1dd018f GIT binary patch literal 62566 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

A%CY_{uSmE?~-EhAQ_;a=!_@ub_7qYnMrD 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 Date: Fri, 4 Sep 2026 13:32:23 +0200 Subject: [PATCH 02/17] fix(swift-sdk): keep deep core diagnostics off startup --- .../CoreWalletDiagnosticAnalyzers.swift | 20 +-- .../PlatformWalletManager.swift | 56 ++++---- ...PlatformWalletManagerCoreDiagnostics.swift | 106 +-------------- .../PlatformWalletPersistenceHandler.swift | 54 ++------ .../CoreWalletDiagnosticAnalyzerTests.swift | 68 +--------- .../CoreWalletDiagnosticsTests.swift | 127 +++++++++++------- .../PlatformWalletShutdownTests.swift | 36 +++++ 7 files changed, 179 insertions(+), 288 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWalletDiagnosticAnalyzers.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWalletDiagnosticAnalyzers.swift index 70dc2540e65..5cd1e2a4cbc 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWalletDiagnosticAnalyzers.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWalletDiagnosticAnalyzers.swift @@ -192,13 +192,13 @@ enum CoreWalletDiagnosticAnalyzer { case invalidAccountType = "invalid_account_type" } - let txo: CoreWalletDatabaseDiagnosticSnapshot.Txo + /// Only the fields required by the restore summary. Keeping this + /// intentionally small prevents the launch-time callback from doing + /// any diagnostic outpoint/script materialization or fingerprinting. + let amount: UInt64 let accountType: UInt32? let standardTag: UInt8? let rejectionReason: RejectionReason? - let isCoinbase: Bool - let isConfirmed: Bool - let isInstantLocked: Bool } struct RestoreBufferSummary: Sendable { @@ -237,23 +237,23 @@ enum CoreWalletDiagnosticAnalyzer { let emittedCoinJoin = emittedCandidates.filter { $0.accountType == 1 } return RestoreBufferSummary( candidateCount: candidates.count, - candidateValueDuffs: diagnosticSaturatingSum(candidates.map(\.txo.amount)), + candidateValueDuffs: diagnosticSaturatingSum(candidates.map(\.amount)), candidateBip44Count: candidateBip44.count, candidateBip44ValueDuffs: diagnosticSaturatingSum( - candidateBip44.map(\.txo.amount) + candidateBip44.map(\.amount) ), candidateCoinJoinCount: candidateCoinJoin.count, candidateCoinJoinValueDuffs: diagnosticSaturatingSum( - candidateCoinJoin.map(\.txo.amount) + candidateCoinJoin.map(\.amount) ), builtCount: emittedCount, emittedCandidates: emittedCandidates, - emittedValueDuffs: diagnosticSaturatingSum(emittedCandidates.map(\.txo.amount)), + emittedValueDuffs: diagnosticSaturatingSum(emittedCandidates.map(\.amount)), emittedBip44Count: emittedBip44.count, - emittedBip44ValueDuffs: diagnosticSaturatingSum(emittedBip44.map(\.txo.amount)), + emittedBip44ValueDuffs: diagnosticSaturatingSum(emittedBip44.map(\.amount)), emittedCoinJoinCount: emittedCoinJoin.count, emittedCoinJoinValueDuffs: diagnosticSaturatingSum( - emittedCoinJoin.map(\.txo.amount) + emittedCoinJoin.map(\.amount) ), missingAccountCount: candidates.filter { $0.rejectionReason == .missingAccount diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 5e28fc5dc4a..a67f7912754 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -467,6 +467,11 @@ public class PlatformWalletManager: ObservableObject { /// data (for create, the caller would roll back its mnemonic and /// orphan the persisted rows). private var activeNativeOpCount = 0 + /// Read-only Core diagnostics have their own admission count. They must + /// keep the manager handle alive until their FFI reads finish, but they do + /// not make synchronous create/load/delete operations unsafe and therefore + /// must not participate in `ensureSyncNativeOpAllowed`. + private var activeCoreDiagnosticsNativeOpCount = 0 private var nativeOpDrainContinuations: [CheckedContinuation] = [] /// Admission + bookkeeping shared by the async native entrypoints: @@ -484,22 +489,32 @@ public class PlatformWalletManager: ObservableObject { private func finishNativeOp() { activeNativeOpCount -= 1 - if activeNativeOpCount == 0, !nativeOpDrainContinuations.isEmpty { - let waiters = nativeOpDrainContinuations - nativeOpDrainContinuations.removeAll() - waiters.forEach { $0.resume() } - } + resumeNativeOpDrainIfIdle() } - /// Diagnostics use the same admission/drain contract as other background - /// native work: once admitted, shutdown cannot consume the manager handle - /// until the read-only snapshot has finished on `destroyQueue`. + /// Diagnostics have independent admission bookkeeping: shutdown drains + /// them, while synchronous wallet operations ignore them. func admitCoreDiagnosticsNativeOp() throws { - try admitNativeOp("coreWalletDiagnostics") + guard !shutdownRequested else { + throw PlatformWalletError.invalidHandle( + "manager shutdown is in progress; coreWalletDiagnostics rejected") + } + activeCoreDiagnosticsNativeOpCount += 1 } func finishCoreDiagnosticsNativeOp() { - finishNativeOp() + activeCoreDiagnosticsNativeOpCount -= 1 + resumeNativeOpDrainIfIdle() + } + + private func resumeNativeOpDrainIfIdle() { + guard activeNativeOpCount == 0, + activeCoreDiagnosticsNativeOpCount == 0, + !nativeOpDrainContinuations.isEmpty + else { return } + let waiters = nativeOpDrainContinuations + nativeOpDrainContinuations.removeAll() + waiters.forEach { $0.resume() } } /// Test seam for the individual native calls. Production keeps `.live`; @@ -635,7 +650,7 @@ public class PlatformWalletManager: ObservableObject { ranOffMainThread: false) } shutdownRequested = true - if activeNativeOpCount == 0 { break } + if activeNativeOpCount == 0, activeCoreDiagnosticsNativeOpCount == 0 { break } await withCheckedContinuation { continuation in nativeOpDrainContinuations.append(continuation) } @@ -1298,8 +1313,6 @@ public class PlatformWalletManager: ObservableObject { /// `createWallet` flow. @discardableResult public func loadFromPersistor() throws -> [ManagedPlatformWallet] { - let diagnosticPersistenceHandler = persistenceHandler - defer { diagnosticPersistenceHandler?.clearStartupCoreDiagnosticSnapshots() } // Same synchronous-admission gate as the sync creates: rejected // during the shutdown drain AND while an async native op is in // flight — a second Rust loader running concurrently with the one @@ -1382,13 +1395,6 @@ public class PlatformWalletManager: ObservableObject { } } - for managedWallet in restored { - emitCoreWalletDiagnosticsSynchronously( - for: managedWallet.walletId, - checkpoint: .startupPostRestore - ) - } - // Kick off a background catch-up pass for every persisted // asset lock at `statusRaw < 2`. Closes the SPV-restart gap: // the wallet's in-memory transactions map was just @@ -1530,13 +1536,12 @@ public class PlatformWalletManager: ObservableObject { /// and once admitted the teardown waits for the full transaction. @discardableResult public func loadFromPersistor() async throws -> [ManagedPlatformWallet] { - let handler = persistenceHandler - defer { handler?.clearStartupCoreDiagnosticSnapshots() } try ensureConfigured() try admitNativeOp("loadFromPersistor") defer { finishNativeOp() } let h = handle + let handler = persistenceHandler let calls = nativeLoadCalls // Direct continuation for the same FIFO reason as the async @@ -1609,13 +1614,6 @@ public class PlatformWalletManager: ObservableObject { ] ) - for managedWallet in restored { - await emitCoreWalletDiagnostics( - for: managedWallet.walletId, - checkpoint: .startupPostRestore - ) - } - catchUpStuckAssetLocks(wallets: restored) return restored } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift index 4ff27a976bf..94140ae5622 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift @@ -3,11 +3,8 @@ import DashSDKFFI import Foundation import SwiftData -/// Named checkpoints make two exports from the same device directly -/// comparable without putting any user-controlled text in the log. +/// Deep Core diagnostics are emitted only on explicit log export. enum CoreWalletDiagnosticCheckpoint: String, Sendable { - case startupPreRestore = "startup_pre_restore" - case startupPostRestore = "startup_post_restore" case preExport = "pre_export" } @@ -154,26 +151,6 @@ func diagnosticTxoFingerprint( return data } -/// Canonical material for one exact `UtxoRestoreEntryFFI` row. The general -/// DB↔memory UTXO query cannot observe these three flags, so they live only in -/// this restore-specific fingerprint instead of creating false memory diffs. -func diagnosticRestoreTxoFingerprint( - _ candidate: CoreWalletDiagnosticAnalyzer.RestoreCandidate -) -> Data { - var data = diagnosticTxoFingerprint( - outpoint: candidate.txo.outpoint, - amount: candidate.txo.amount, - height: candidate.txo.height, - scriptPubKey: candidate.txo.scriptPubKey, - isLocked: candidate.txo.isLocked, - account: candidate.txo.account - ) - data.append(candidate.isCoinbase ? 1 : 0) - data.append(candidate.isConfirmed ? 1 : 0) - data.append(candidate.isInstantLocked ? 1 : 0) - return data -} - extension PlatformWalletPersistenceHandler { /// Main-actor-friendly entry point used by manual log export. The handler's /// serial queue owns the ModelContext; only a Sendable value snapshot is @@ -185,18 +162,6 @@ extension PlatformWalletPersistenceHandler { await withCheckedContinuation { continuation in serialQueue.async { [self] in let snapshot = autoreleasepool { () -> CoreWalletDatabaseDiagnosticSnapshot? in - if checkpoint == .startupPostRestore, - let cached = startupCoreDiagnosticSnapshots.removeValue(forKey: walletId) { - SDKLogger.event( - "core_db_startup_snapshot_reused", - category: .persistence, - fields: [ - "checkpoint": .publicText(checkpoint.rawValue), - "wallet_reference": .reference(walletId), - ] - ) - return cached - } return emitCoreWalletDatabaseDiagnosticsOnQueue( walletId: walletId, checkpoint: checkpoint @@ -207,24 +172,12 @@ extension PlatformWalletPersistenceHandler { } } - /// Synchronous companion for the legacy synchronous restore overload. + /// Synchronous companion used by focused persistence tests. func emitCoreWalletDatabaseDiagnostics( walletId: Data, checkpoint: CoreWalletDiagnosticCheckpoint ) -> CoreWalletDatabaseDiagnosticSnapshot? { onQueue { - if checkpoint == .startupPostRestore, - let cached = startupCoreDiagnosticSnapshots.removeValue(forKey: walletId) { - SDKLogger.event( - "core_db_startup_snapshot_reused", - category: .persistence, - fields: [ - "checkpoint": .publicText(checkpoint.rawValue), - "wallet_reference": .reference(walletId), - ] - ) - return cached - } return emitCoreWalletDatabaseDiagnosticsOnQueue( walletId: walletId, checkpoint: checkpoint @@ -232,19 +185,12 @@ extension PlatformWalletPersistenceHandler { } } - /// Must be called while `serialQueue` is held. `loadWalletList` uses this - /// directly, avoiding a recursive `serialQueue.sync` deadlock. + /// Must be called while `serialQueue` is held. @discardableResult func emitCoreWalletDatabaseDiagnosticsOnQueue( walletId: Data, checkpoint: CoreWalletDiagnosticCheckpoint ) -> CoreWalletDatabaseDiagnosticSnapshot? { - // A previous restore can fail after the pre-snapshot was cached but - // before post-restore consumes it. Never let a later attempt compare - // Rust against that stale value. - if checkpoint == .startupPreRestore { - startupCoreDiagnosticSnapshots.removeValue(forKey: walletId) - } do { let walletDescriptor = FetchDescriptor( predicate: PersistentWallet.predicate(walletId: walletId) @@ -530,9 +476,6 @@ extension PlatformWalletPersistenceHandler { assetLocks: assetLocks, assetLocksAvailable: assetLocksAvailable ) - if checkpoint == .startupPreRestore { - startupCoreDiagnosticSnapshots[walletId] = snapshot - } return snapshot } catch { SDKLogger.event( @@ -571,20 +514,10 @@ extension PlatformWalletPersistenceHandler { rejection = nil } return CoreWalletDiagnosticAnalyzer.RestoreCandidate( - txo: CoreWalletDatabaseDiagnosticSnapshot.Txo( - outpoint: PersistentTxo.makeOutpoint(txid: row.txid, vout: row.vout), - amount: row.amount, - height: row.height, - scriptPubKey: row.scriptPubKey, - isLocked: row.isLocked, - account: Self.diagnosticAccountKey(row.account) - ), + amount: row.amount, accountType: row.account?.accountType, standardTag: row.account?.standardTag, - rejectionReason: rejection, - isCoinbase: row.isCoinbase, - isConfirmed: row.isConfirmed, - isInstantLocked: row.isInstantLocked + rejectionReason: rejection ) } // A validation error deallocates the compact buffer and aborts the @@ -595,7 +528,6 @@ extension PlatformWalletPersistenceHandler { emittedCount: emittedCount, errored: errored ) - let emittedMaterials = summary.emittedCandidates.map(diagnosticRestoreTxoFingerprint) let hasRejectedRows = summary.missingAccountCount > 0 || summary.invalidTxidCount > 0 || summary.invalidAccountTypeCount > 0 @@ -616,7 +548,7 @@ extension PlatformWalletPersistenceHandler { ), "candidate_value_duffs": .unsignedInteger(summary.candidateValueDuffs), "built_count": .integer(Int64(summary.builtCount)), - "checkpoint": .publicText(CoreWalletDiagnosticCheckpoint.startupPreRestore.rawValue), + "checkpoint": .publicText("restore_buffer"), "emitted_count": .integer(Int64(summary.emittedCandidates.count)), "emitted_bip44_count": .integer(Int64(summary.emittedBip44Count)), "emitted_bip44_value_duffs": .unsignedInteger( @@ -626,7 +558,6 @@ extension PlatformWalletPersistenceHandler { "emitted_coinjoin_value_duffs": .unsignedInteger( summary.emittedCoinJoinValueDuffs ), - "emitted_fingerprint": .reference(diagnosticFingerprint(emittedMaterials)), "emitted_value_duffs": .unsignedInteger(summary.emittedValueDuffs), "errored": .boolean(errored), "skipped_invalid_account_type_count": .integer( @@ -1026,7 +957,7 @@ extension PlatformWalletManager { await emitCoreWalletDiagnostics(for: walletId, checkpoint: .preExport) } - func emitCoreWalletDiagnostics( + private func emitCoreWalletDiagnostics( for walletId: Data, checkpoint: CoreWalletDiagnosticCheckpoint ) async { @@ -1096,29 +1027,6 @@ extension PlatformWalletManager { } } - /// Blocking variant used only by the already-blocking synchronous restore - /// API. New application code should use the async public entry point. - func emitCoreWalletDiagnosticsSynchronously( - for walletId: Data, - checkpoint: CoreWalletDiagnosticCheckpoint - ) { - guard walletId.count == 32, - let handler = persistence, - let database = handler.emitCoreWalletDatabaseDiagnostics( - walletId: walletId, - checkpoint: checkpoint - ), - isConfigured, - handle != NULL_HANDLE - else { return } - Self.emitCoreMemoryDiagnostics( - managerHandle: handle, - managedWallet: wallets[walletId], - database: database, - checkpoint: checkpoint - ) - } - private nonisolated static func emitCoreMemoryDiagnostics( managerHandle: Handle, managedWallet: ManagedPlatformWallet?, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 9d0fb6731b3..ab4537caca0 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -242,16 +242,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } - /// Clears pre-restore diagnostic values that were not consumed by a - /// successful post-restore comparison. Safe to call from manager failure - /// and skipped-wallet paths; do not call recursively while `serialQueue` - /// is already held. - func clearStartupCoreDiagnosticSnapshots() { - onQueue { - startupCoreDiagnosticSnapshots.removeAll(keepingCapacity: true) - } - } - /// Best-effort save used by callback helpers that may also be invoked /// outside a Rust changeset. The legacy behavior remains non-throwing, /// but failures are no longer invisible in exported diagnostics. @@ -5194,16 +5184,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ] ) return onQueue { - // Start every bulk attempt from an empty cache. Retain the snapshots - // only when the complete FFI buffer is handed back successfully; - // every validation/fetch/allocation failure exits through this defer. - startupCoreDiagnosticSnapshots.removeAll(keepingCapacity: true) - var preserveStartupDiagnosticSnapshots = false - defer { - if !preserveStartupDiagnosticSnapshots { - startupCoreDiagnosticSnapshots.removeAll(keepingCapacity: true) - } - } healIdentityIsLocalFlags() // Scope the fetch to the handler's bound network so a // per-network manager only sees its own wallets. If @@ -5251,17 +5231,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return (nil, 0, false) } - // Capture the durable source-of-truth before any bytes cross the FFI - // boundary. We are already on `serialQueue`, so call the on-queue - // implementation directly (the public wrapper would deadlock by - // recursively entering `serialQueue.sync`). - for wallet in restorable { - emitCoreWalletDatabaseDiagnosticsOnQueue( - walletId: wallet.walletId, - checkpoint: .startupPreRestore - ) - } - // Single bucketed fetch of every unspent `PersistentTxo` so // each wallet's per-iteration buffer build is a dictionary // lookup instead of a fresh database round-trip. Prefetches @@ -5276,6 +5245,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // called on the path through `loadAllocations` after the // pointer hand-off to Rust succeeds). var unspentBuckets: [Data: [PersistentTxo]] = [:] + // Mirrors the already-required restore fetch, but retains rows that + // have a denormalized wallet id and no account so the lightweight + // restore summary can report why they were not handed to Rust. + var restoreDiagnosticBuckets: [Data: [PersistentTxo]] = [:] do { var unspentDescriptor = FetchDescriptor( predicate: #Predicate { $0.isSpent == false } @@ -5381,15 +5354,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } unspentBuckets.reserveCapacity(restorable.count) + restoreDiagnosticBuckets.reserveCapacity(restorable.count) for row in liveUnspent { - guard row.account != nil else { continue } let key: Data if !row.walletId.isEmpty { - // Keep a denorm-scoped row even when its account - // relationship is missing. `buildUtxoRestoreBuffer` - // still skips it exactly as before, while the adjacent - // diagnostic summary can now report the rejection instead - // of silently losing the evidence. key = row.walletId } else if let account = row.account { // `account.wallet` is non-optional on the @@ -5402,6 +5370,11 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } else { continue } + restoreDiagnosticBuckets[key, default: []].append(row) + // Preserve the upstream restore contract: account-less rows + // are diagnostic candidates only and never enter FFI + // marshalling. + guard row.account != nil else { continue } unspentBuckets[key, default: []].append(row) } } @@ -5622,13 +5595,15 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // the matching funds-bearing account by tag; rows whose // account isn't a funds variant get silently skipped on // the receiving side. + let restoreRows = unspentBuckets[w.walletId] ?? [] + let diagnosticRows = restoreDiagnosticBuckets[w.walletId] ?? restoreRows let (utxoBuf, utxoCount, utxoErrored) = buildUtxoRestoreBuffer( - rows: unspentBuckets[w.walletId] ?? [], + rows: restoreRows, allocation: allocation ) logCoreRestoreBufferSnapshotOnQueue( walletId: w.walletId, - rows: unspentBuckets[w.walletId] ?? [], + rows: diagnosticRows, emittedCount: utxoCount, errored: utxoErrored ) @@ -5723,7 +5698,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { category: .persistence, fields: ["wallet_count": .integer(Int64(restorable.count))] ) - preserveStartupDiagnosticSnapshots = true return (typed, restorable.count, false) } // onQueue } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticAnalyzerTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticAnalyzerTests.swift index 70e4a88d6be..c38f9569dc2 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticAnalyzerTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticAnalyzerTests.swift @@ -204,23 +204,17 @@ final class CoreWalletDiagnosticAnalyzerTests: XCTestCase { XCTAssertEqual(anomalies.count(reason: "missing_account"), 1) let rejected = CoreWalletDiagnosticAnalyzer.RestoreCandidate( - txo: missingAccountTxo, + amount: missingAccountTxo.amount, accountType: nil, standardTag: nil, - rejectionReason: .missingAccount, - isCoinbase: false, - isConfirmed: true, - isInstantLocked: false + rejectionReason: .missingAccount ) let acceptedTxo = txo(0x31, amount: 800) let accepted = CoreWalletDiagnosticAnalyzer.RestoreCandidate( - txo: acceptedTxo, + amount: acceptedTxo.amount, accountType: 0, standardTag: 0, - rejectionReason: nil, - isCoinbase: false, - isConfirmed: true, - isInstantLocked: false + rejectionReason: nil ) let summary = CoreWalletDiagnosticAnalyzer.summarizeRestoreBuffer( candidates: [rejected, accepted], @@ -232,7 +226,7 @@ final class CoreWalletDiagnosticAnalyzerTests: XCTestCase { XCTAssertEqual(summary.candidateValueDuffs, 1_500) XCTAssertEqual(summary.missingAccountCount, 1) XCTAssertEqual(summary.emittedCandidates.count, 1) - XCTAssertEqual(summary.emittedCandidates.first?.txo.outpoint, acceptedTxo.outpoint) + XCTAssertEqual(summary.emittedCandidates.first?.amount, acceptedTxo.amount) XCTAssertEqual(summary.emittedValueDuffs, 800) } @@ -284,58 +278,6 @@ final class CoreWalletDiagnosticAnalyzerTests: XCTestCase { ) } - func testRestoreFingerprintIncludesEveryRestoreOnlyFlag() { - let base = CoreWalletDiagnosticAnalyzer.RestoreCandidate( - txo: txo(0x50), - accountType: 0, - standardTag: 0, - rejectionReason: nil, - isCoinbase: false, - isConfirmed: false, - isInstantLocked: false - ) - let coinbase = CoreWalletDiagnosticAnalyzer.RestoreCandidate( - txo: base.txo, - accountType: base.accountType, - standardTag: base.standardTag, - rejectionReason: nil, - isCoinbase: true, - isConfirmed: false, - isInstantLocked: false - ) - let confirmed = CoreWalletDiagnosticAnalyzer.RestoreCandidate( - txo: base.txo, - accountType: base.accountType, - standardTag: base.standardTag, - rejectionReason: nil, - isCoinbase: false, - isConfirmed: true, - isInstantLocked: false - ) - let instantLocked = CoreWalletDiagnosticAnalyzer.RestoreCandidate( - txo: base.txo, - accountType: base.accountType, - standardTag: base.standardTag, - rejectionReason: nil, - isCoinbase: false, - isConfirmed: false, - isInstantLocked: true - ) - - XCTAssertNotEqual( - diagnosticRestoreTxoFingerprint(base), - diagnosticRestoreTxoFingerprint(coinbase) - ) - XCTAssertNotEqual( - diagnosticRestoreTxoFingerprint(base), - diagnosticRestoreTxoFingerprint(confirmed) - ) - XCTAssertNotEqual( - diagnosticRestoreTxoFingerprint(base), - diagnosticRestoreTxoFingerprint(instantLocked) - ) - } - func testRescanDiagnosticResultOnlyReportsArmedForARealRewind() { XCTAssertEqual( coreRescanDiagnosticResult( diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticsTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticsTests.swift index 424b714c140..012c7de191f 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticsTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticsTests.swift @@ -241,60 +241,93 @@ final class CoreWalletDiagnosticsTests: XCTestCase { XCTAssertTrue(try logLines(in: session, event: "core_owned_output_anomaly").isEmpty) } - func testStartupPreRestoreClearsStaleSnapshotBeforeAFailedRefresh() throws { - let container = try DashModelContainer.createInMemory() - let handler = PlatformWalletPersistenceHandler( - modelContainer: container, - network: .testnet + func testRestoreOnlyLogsLightweightBufferSnapshotAndNoDeepStartupEvents() throws { + let fixture = try makeMissingOwnedOutputFixture() + fixture.bip44Account.accountExtendedPubKeyBytes = Data(repeating: 0x02, count: 78) + + let validTransaction = PersistentTransaction( + txid: Data(repeating: 0x22, count: 32), + transactionData: Data(), + context: 2, + blockHeight: 102, + netAmount: 100 ) - let stale = CoreWalletDatabaseDiagnosticSnapshot( - walletId: walletId, - accounts: [], - unspentTxos: [], - assetLocks: [], - assetLocksAvailable: true + fixture.context.insert(validTransaction) + let validTxo = PersistentTxo( + transaction: validTransaction, + vout: 0, + amount: 100, + address: "valid-restore-address", + scriptPubKey: Data([0x51]), + height: 102 ) + validTxo.account = fixture.bip44Account + validTxo.walletId = walletId + validTxo.isConfirmed = true + fixture.context.insert(validTxo) - handler.onQueue { - handler.startupCoreDiagnosticSnapshots[walletId] = stale - XCTAssertNil(handler.emitCoreWalletDatabaseDiagnosticsOnQueue( - walletId: walletId, - checkpoint: .startupPreRestore - )) - XCTAssertNil(handler.startupCoreDiagnosticSnapshots[walletId]) - } - } - - func testStartupCacheClearDropsEveryUnconsumedSnapshot() throws { - let container = try DashModelContainer.createInMemory() - let handler = PlatformWalletPersistenceHandler( - modelContainer: container, - network: .testnet - ) - let first = CoreWalletDatabaseDiagnosticSnapshot( - walletId: walletId, - accounts: [], - unspentTxos: [], - assetLocks: [], - assetLocksAvailable: true + let missingAccountTransaction = PersistentTransaction( + txid: Data(repeating: 0x33, count: 32), + transactionData: Data(), + context: 2, + blockHeight: 103, + netAmount: 200 ) - let secondId = Data(repeating: 0xb2, count: 32) - let second = CoreWalletDatabaseDiagnosticSnapshot( - walletId: secondId, - accounts: [], - unspentTxos: [], - assetLocks: [], - assetLocksAvailable: true + fixture.context.insert(missingAccountTransaction) + let missingAccountTxo = PersistentTxo( + transaction: missingAccountTransaction, + vout: 0, + amount: 200, + address: "missing-account-restore-address", + scriptPubKey: Data([0x52]), + height: 103 ) - handler.onQueue { - handler.startupCoreDiagnosticSnapshots[walletId] = first - handler.startupCoreDiagnosticSnapshots[secondId] = second - } + missingAccountTxo.walletId = walletId + missingAccountTxo.isConfirmed = true + fixture.context.insert(missingAccountTxo) + try fixture.context.save() - handler.clearStartupCoreDiagnosticSnapshots() + let session = try temporaryDirectory() + XCTAssertTrue(SDKLogger.installFileSink(at: session, includeDebug: false)) + + let result = fixture.handler.loadWalletList() + XCTAssertFalse(result.errored) + XCTAssertEqual(result.count, 1) + let entries = try XCTUnwrap(result.entries) + defer { fixture.handler.loadWalletListFree(entries: UnsafeRawPointer(entries)) } - handler.onQueue { - XCTAssertTrue(handler.startupCoreDiagnosticSnapshots.isEmpty) + let snapshots = try logLines(in: session, event: "core_restore_buffer_snapshot") + let snapshot = try XCTUnwrap(snapshots.last) + XCTAssertTrue(snapshot.contains("candidate_count=2"), snapshot) + XCTAssertTrue(snapshot.contains("candidate_value_duffs=300"), snapshot) + XCTAssertTrue(snapshot.contains("emitted_count=1"), snapshot) + XCTAssertTrue(snapshot.contains("emitted_value_duffs=100"), snapshot) + XCTAssertTrue(snapshot.contains("skipped_missing_account_count=1"), snapshot) + XCTAssertTrue(snapshot.contains(#"checkpoint="restore_buffer""#), snapshot) + XCTAssertFalse(snapshot.contains("fingerprint"), snapshot) + + SDKLogger.flush() + let completeLog = try String( + contentsOf: session.appendingPathComponent("swift/run.log"), + encoding: .utf8 + ) + let deepStartupEvents = [ + "core_db_wallet_snapshot", + "core_db_account_snapshot", + "core_db_anomaly_summary", + "core_db_txo_anomaly", + "core_owned_output_audit_summary", + "core_owned_output_anomaly", + "asset_lock_db_snapshot", + "shielded_store_snapshot", + "core_memory_account_snapshot", + "core_db_memory_diff_summary", + "core_db_memory_diff", + "asset_lock_memory_snapshot", + "asset_lock_db_memory_diff_summary", + ] + for event in deepStartupEvents { + XCTAssertFalse(completeLog.contains("event=\(event) "), event) } } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PlatformWalletShutdownTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PlatformWalletShutdownTests.swift index 081b070a955..9e43267fbfc 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PlatformWalletShutdownTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PlatformWalletShutdownTests.swift @@ -141,6 +141,42 @@ final class PlatformWalletShutdownTests: XCTestCase { XCTAssertEqual(metrics.steps.map(\.name), Self.expectedOrder) } + func testDiagnosticsDoNotBlockSyncAdmissionButShutdownWaitsForThem() async throws { + let recorder = TeardownRecorder() + let manager = PlatformWalletManager.makeForTesting( + handle: 19, + calls: Self.makeCalls(recorder: recorder) + ) + + try manager.admitCoreDiagnosticsNativeOp() + + // Diagnostics only perform read-only FFI work. A synchronous wallet + // operation must therefore pass native-op admission while diagnostics + // are active. The empty seed then fails at its own validation seam, + // proving admission did not reject it as an overlapping native op. + XCTAssertThrowsError( + try manager.createWallet(seed: Data(), network: .testnet) + ) { error in + guard let walletError = error as? PlatformWalletError, + case .invalidParameter = walletError + else { + return XCTFail("expected invalidParameter, got \(error)") + } + } + + let shutdownTask = Task { await manager.shutdown() } + try await Task.sleep(for: .milliseconds(20)) + XCTAssertEqual(manager.handle, 19, "shutdown must not take the handle early") + XCTAssertTrue(recorder.names.isEmpty, "native teardown must wait for diagnostics") + + manager.finishCoreDiagnosticsNativeOp() + let metrics = await shutdownTask.value + + XCTAssertEqual(manager.handle, NULL_HANDLE) + XCTAssertEqual(metrics.steps.map(\.name), Self.expectedOrder) + XCTAssertEqual(recorder.names, Self.expectedOrder) + } + /// A completed real shutdown makes this manager terminal. Reconfiguration /// must fail before another native handle or callback context is installed. func testConfigurationAfterRealShutdownIsRejected() async { From 345134c65df04e6dbfc55f78ab9db68fdb406a2a Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Fri, 4 Sep 2026 15:57:39 +0200 Subject: [PATCH 03/17] fix(swift-sdk): address diagnostics review --- .../Persistence/DashModelContainer.swift | 108 +++++++++++------- .../CoreWalletDiagnosticAnalyzers.swift | 24 ++++ .../PlatformWalletManager.swift | 18 ++- ...PlatformWalletManagerCoreDiagnostics.swift | 52 ++++++--- .../PlatformWalletPersistenceHandler.swift | 13 ++- .../AssetLockSpendVisibilityTests.swift | 58 +++++++++- .../CoreWalletDiagnosticsTests.swift | 14 ++- .../PlatformWalletShutdownTests.swift | 23 ++++ 8 files changed, 235 insertions(+), 75 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index a6e5dd9bd38..fb5c8d530d3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -16,6 +16,56 @@ public enum DashModelContainer { } } + /// Builds the common payload for both sides of the container open. The + /// outcome deliberately describes only what SwiftData tells us: opening an + /// existing store may have included a migration, but this API does not + /// expose whether one actually ran. + private static func storeOpenFields( + succeeded: Bool, + existedBefore: Bool, + startedAt: CFAbsoluteTime, + sizeBefore: StoreFileSizes, + sizeAfter: StoreFileSizes + ) -> [String: SDKLogValue] { + let elapsed = max(0, (CFAbsoluteTimeGetCurrent() - startedAt) * 1_000) + let duration: UInt64 + if !elapsed.isFinite { + duration = 0 + } else if elapsed >= Double(UInt64.max) { + duration = UInt64.max + } else { + duration = UInt64(elapsed) + } + let openOutcome: String + switch (succeeded, existedBefore) { + case (true, true): + openOutcome = "existing_store_opened" + case (true, false): + openOutcome = "new_store_created" + case (false, true): + openOutcome = "existing_store_open_or_migration_failed" + case (false, false): + openOutcome = "new_store_creation_failed" + } + + return [ + "container_result": .publicText(succeeded ? "opened" : "open_failed"), + "container_reused": .boolean(false), + "duration_ms": .unsignedInteger(duration), + "result": .publicText(succeeded ? "success" : "failure"), + "store_existed_before_open": .boolean(existedBefore), + "store_main_size_bytes_after": .unsignedInteger(sizeAfter.main), + "store_main_size_bytes_before": .unsignedInteger(sizeBefore.main), + "store_open_outcome": .publicText(openOutcome), + "store_shm_size_bytes_after": .unsignedInteger(sizeAfter.shm), + "store_shm_size_bytes_before": .unsignedInteger(sizeBefore.shm), + "store_size_bytes_after": .unsignedInteger(sizeAfter.total), + "store_size_bytes_before": .unsignedInteger(sizeBefore.total), + "store_wal_size_bytes_after": .unsignedInteger(sizeAfter.wal), + "store_wal_size_bytes_before": .unsignedInteger(sizeBefore.wal), + ] + } + /// SQLite's durable state can be mostly in the WAL immediately after an /// app kill, so the main file alone is not a useful corruption signal. /// Read only sizes and never include any component of the device path. @@ -144,27 +194,13 @@ public enum DashModelContainer { SDKLogger.event( "core_store_open_result", category: .persistence, - fields: [ - "container_result": .publicText("opened"), - "container_reused": .boolean(false), - "duration_ms": .unsignedInteger(UInt64(max( - 0, - Int((CFAbsoluteTimeGetCurrent() - started) * 1_000) - ))), - "migration_result": .publicText( - existedBefore ? "store_open_succeeded" : "not_required_new_store" - ), - "result": .publicText("success"), - "store_existed_before_open": .boolean(existedBefore), - "store_main_size_bytes_after": .unsignedInteger(sizeAfter.main), - "store_main_size_bytes_before": .unsignedInteger(sizeBefore.main), - "store_shm_size_bytes_after": .unsignedInteger(sizeAfter.shm), - "store_shm_size_bytes_before": .unsignedInteger(sizeBefore.shm), - "store_size_bytes_after": .unsignedInteger(sizeAfter.total), - "store_size_bytes_before": .unsignedInteger(sizeBefore.total), - "store_wal_size_bytes_after": .unsignedInteger(sizeAfter.wal), - "store_wal_size_bytes_before": .unsignedInteger(sizeBefore.wal), - ] + fields: storeOpenFields( + succeeded: true, + existedBefore: existedBefore, + startedAt: started, + sizeBefore: sizeBefore, + sizeAfter: sizeAfter + ) ) return container } catch { @@ -173,29 +209,13 @@ public enum DashModelContainer { "core_store_open_result", category: .persistence, severity: .error, - fields: [ - "container_result": .publicText("open_failed"), - "container_reused": .boolean(false), - "duration_ms": .unsignedInteger(UInt64(max( - 0, - Int((CFAbsoluteTimeGetCurrent() - started) * 1_000) - ))), - "migration_result": .publicText( - existedBefore - ? "store_open_or_migration_failed" - : "not_attempted_new_store_create_failed" - ), - "result": .publicText("failure"), - "store_existed_before_open": .boolean(existedBefore), - "store_main_size_bytes_after": .unsignedInteger(sizeAfter.main), - "store_main_size_bytes_before": .unsignedInteger(sizeBefore.main), - "store_shm_size_bytes_after": .unsignedInteger(sizeAfter.shm), - "store_shm_size_bytes_before": .unsignedInteger(sizeBefore.shm), - "store_size_bytes_after": .unsignedInteger(sizeAfter.total), - "store_size_bytes_before": .unsignedInteger(sizeBefore.total), - "store_wal_size_bytes_after": .unsignedInteger(sizeAfter.wal), - "store_wal_size_bytes_before": .unsignedInteger(sizeBefore.wal), - ], + fields: storeOpenFields( + succeeded: false, + existedBefore: existedBefore, + startedAt: started, + sizeBefore: sizeBefore, + sizeAfter: sizeAfter + ), error: error, redacting: [storeURL.path] ) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWalletDiagnosticAnalyzers.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWalletDiagnosticAnalyzers.swift index 5cd1e2a4cbc..ca9fa8bc0a3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWalletDiagnosticAnalyzers.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWalletDiagnosticAnalyzers.swift @@ -5,12 +5,15 @@ import Foundation /// decisions that produce `swift/run.log`, without requiring a live Rust /// wallet handle. enum CoreWalletDiagnosticAnalyzer { + /// One deterministic mismatch record. `outpoint` is hashed by the logger + /// and is never rendered directly. struct TxoDiffDetail: Sendable { let outpoint: Data let reason: String let row: CoreWalletDatabaseDiagnosticSnapshot.Txo } + /// Aggregate DB↔Rust account/UTXO comparison plus bounded log details. struct TxoDiff: Sendable { let commonCount: Int let databaseAccountOnlyCount: Int @@ -23,6 +26,8 @@ enum CoreWalletDiagnosticAnalyzer { let truncatedCount: Int } + /// Compares UTXOs by outpoint and reports each differing field separately. + /// Duplicate outpoints are resolved deterministically before comparison. static func compareTxos( database: [CoreWalletDatabaseDiagnosticSnapshot.Txo], memory: [CoreWalletDatabaseDiagnosticSnapshot.Txo], @@ -114,17 +119,22 @@ enum CoreWalletDiagnosticAnalyzer { ) } + /// One AssetLock mismatch, retaining the display outpoint only so the + /// logger can derive a stable reference from it. struct AssetLockDiffDetail: Sendable { let outpointDisplay: String let reason: String } + /// Complete AssetLock mismatch set and its per-reason bounded projection. struct AssetLockDiff: Sendable { let details: [AssetLockDiffDetail] let emittedDetails: [AssetLockDiffDetail] let truncatedCount: Int } + /// Compares persisted and Rust-tracked locks without exposing transaction + /// or proof bytes. static func compareAssetLocks( database: [CoreWalletDatabaseDiagnosticSnapshot.AssetLock], memory: [CoreWalletDatabaseDiagnosticSnapshot.AssetLock] @@ -185,6 +195,7 @@ enum CoreWalletDiagnosticAnalyzer { ) } + /// Lightweight description of a row considered by startup restore. struct RestoreCandidate: Sendable { enum RejectionReason: String, Sendable { case missingAccount = "missing_account" @@ -201,6 +212,8 @@ enum CoreWalletDiagnosticAnalyzer { let rejectionReason: RejectionReason? } + /// Counts and values for candidates, rows actually emitted, and each + /// validation rejection reason. struct RestoreBufferSummary: Sendable { let candidateCount: Int let candidateValueDuffs: UInt64 @@ -220,6 +233,8 @@ enum CoreWalletDiagnosticAnalyzer { let invalidAccountTypeCount: Int } + /// Reconciles the candidate list with the compact FFI buffer length. An + /// errored build reports zero emitted rows even if validation failed late. static func summarizeRestoreBuffer( candidates: [RestoreCandidate], emittedCount: Int, @@ -267,6 +282,7 @@ enum CoreWalletDiagnosticAnalyzer { ) } + /// Persistent facts used to detect malformed or contradictory TXO rows. struct DatabaseTxoAuditRow: Sendable { let txo: CoreWalletDatabaseDiagnosticSnapshot.Txo let hasParentTransaction: Bool @@ -275,11 +291,13 @@ enum CoreWalletDiagnosticAnalyzer { let hasSpendingTransaction: Bool } + /// A database anomaly whose raw TXO identity is later hashed by the logger. struct DatabaseTxoAnomaly: Sendable { let txo: CoreWalletDatabaseDiagnosticSnapshot.Txo let reason: String } + /// Complete database anomaly set and its per-reason bounded projection. struct DatabaseTxoAnomalyResult: Sendable { let details: [DatabaseTxoAnomaly] let emittedDetails: [DatabaseTxoAnomaly] @@ -290,6 +308,7 @@ enum CoreWalletDiagnosticAnalyzer { } } + /// Derives all applicable anomaly reasons for every supplied row. static func databaseTxoAnomalies( _ rows: [DatabaseTxoAuditRow] ) -> DatabaseTxoAnomalyResult { @@ -338,11 +357,14 @@ enum CoreWalletDiagnosticAnalyzer { return .init(details: details, emittedDetails: emitted, truncatedCount: truncated) } + /// Value and spent state of a shielded note; identifiers are unnecessary + /// for the aggregate store diagnostic. struct ShieldedNote: Sendable { let value: UInt64 let isSpent: Bool } + /// Aggregate shielded persistence state used by the exported snapshot. struct ShieldedStoreSummary: Sendable { let noteCount: Int let spentNoteCount: Int @@ -358,6 +380,8 @@ enum CoreWalletDiagnosticAnalyzer { let maximumSyncWatermark: UInt64 } + /// Aggregates shielded note values, activity state, keys, and watermarks + /// without retaining any note or viewing-key identifiers. static func summarizeShieldedStore( notes: [ShieldedNote], outgoingNoteCount: Int, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index a67f7912754..45060d28f02 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -492,8 +492,11 @@ public class PlatformWalletManager: ObservableObject { resumeNativeOpDrainIfIdle() } - /// Diagnostics have independent admission bookkeeping: shutdown drains - /// them, while synchronous wallet operations ignore them. + /// Reserves the native manager handle for one read-only diagnostic pass. + /// Shutdown drains this counter, while synchronous wallet operations + /// intentionally ignore it because Rust serializes its own wallet state. + /// Every successful admission must be balanced by + /// ``finishCoreDiagnosticsNativeOp()``. func admitCoreDiagnosticsNativeOp() throws { guard !shutdownRequested else { throw PlatformWalletError.invalidHandle( @@ -502,7 +505,18 @@ public class PlatformWalletManager: ObservableObject { activeCoreDiagnosticsNativeOpCount += 1 } + /// Releases a successful diagnostic admission. The guard keeps a future + /// shutdown from observing a negative counter if an internal caller ever + /// violates the admission/defer contract. func finishCoreDiagnosticsNativeOp() { + guard activeCoreDiagnosticsNativeOpCount > 0 else { + SDKLogger.event( + "core_diagnostics_native_op_counter_underflow", + category: .lifecycle, + severity: .error + ) + return + } activeCoreDiagnosticsNativeOpCount -= 1 resumeNativeOpDrainIfIdle() } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift index 94140ae5622..1a7bc2e7c09 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift @@ -3,14 +3,19 @@ import DashSDKFFI import Foundation import SwiftData -/// Deep Core diagnostics are emitted only on explicit log export. +/// Labels the two Core diagnostic paths without accepting free-form strings. +/// Only ``preExport`` performs database and Rust-memory inspection; +/// ``restoreBuffer`` labels the lightweight summary built from rows that the +/// restore callback had to fetch and marshal anyway. enum CoreWalletDiagnosticCheckpoint: String, Sendable { + case restoreBuffer = "restore_buffer" case preExport = "pre_export" } /// Value-only copy of the SwiftData state used after the handler has released /// its serial queue. No SwiftData model object crosses the queue boundary. struct CoreWalletDatabaseDiagnosticSnapshot: Sendable { + /// Canonical account tuple shared by SwiftData and Rust FFI snapshots. struct AccountKey: Hashable, Sendable { let typeTag: UInt32 let standardTag: UInt8 @@ -59,6 +64,7 @@ struct CoreWalletDatabaseDiagnosticSnapshot: Sendable { } } + /// Minimal owned-output representation required for deterministic diffing. struct Txo: Sendable { let outpoint: Data let amount: UInt64 @@ -68,6 +74,8 @@ struct CoreWalletDatabaseDiagnosticSnapshot: Sendable { let account: AccountKey? } + /// Comparable subset of one tracked AssetLock; no transaction or proof + /// bytes cross the SwiftData queue boundary. struct AssetLock: Sendable { let outpointDisplay: String let fundingType: Int @@ -98,6 +106,7 @@ private extension Data { } } +/// Adds diagnostic values without allowing corrupt data to trap the exporter. func diagnosticSaturatingSum(_ values: S) -> UInt64 where S.Element == UInt64 { values.reduce(0) { partial, value in @@ -115,6 +124,8 @@ where S.Element == Int64 { } } +/// Hashes length-delimited canonical records after sorting, making the result +/// stable across SwiftData/Rust iteration order without logging raw records. func diagnosticFingerprint(_ records: [Data]) -> Data { var hasher = SHA256() for record in records.sorted(by: { $0.lexicographicallyPrecedes($1) }) { @@ -125,6 +136,8 @@ func diagnosticFingerprint(_ records: [Data]) -> Data { return Data(hasher.finalize()) } +/// Canonical binary representation of every TXO field compared by the +/// database↔memory analyzer. The caller hashes this value before logging it. func diagnosticTxoFingerprint( outpoint: Data, amount: UInt64, @@ -172,20 +185,9 @@ extension PlatformWalletPersistenceHandler { } } - /// Synchronous companion used by focused persistence tests. - func emitCoreWalletDatabaseDiagnostics( - walletId: Data, - checkpoint: CoreWalletDiagnosticCheckpoint - ) -> CoreWalletDatabaseDiagnosticSnapshot? { - onQueue { - return emitCoreWalletDatabaseDiagnosticsOnQueue( - walletId: walletId, - checkpoint: checkpoint - ) - } - } - - /// Must be called while `serialQueue` is held. + /// Queue-confined implementation behind the async export API. Callers must + /// already own `serialQueue`; it intentionally performs the full exact + /// audit and returns only Sendable value copies. @discardableResult func emitCoreWalletDatabaseDiagnosticsOnQueue( walletId: Data, @@ -209,6 +211,11 @@ extension PlatformWalletPersistenceHandler { return nil } + // Exact #4438 classification needs a complete cross-wallet pass: + // an output absent from this wallet may be `wrong_wallet`, not + // `missing_txo`. This first export-only implementation materializes + // that pass. A future bounded version must stream every row rather + // than apply a fetch limit, so it preserves the distinction. let allTxos = try backgroundContext.fetch(FetchDescriptor()) let walletTxos = allTxos.filter { $0.walletId == walletId || Self.relationshipWalletId(of: $0) == walletId @@ -548,7 +555,7 @@ extension PlatformWalletPersistenceHandler { ), "candidate_value_duffs": .unsignedInteger(summary.candidateValueDuffs), "built_count": .integer(Int64(summary.builtCount)), - "checkpoint": .publicText("restore_buffer"), + "checkpoint": .publicText(CoreWalletDiagnosticCheckpoint.restoreBuffer.rawValue), "emitted_count": .integer(Int64(summary.emittedCandidates.count)), "emitted_bip44_count": .integer(Int64(summary.emittedBip44Count)), "emitted_bip44_value_duffs": .unsignedInteger( @@ -957,6 +964,9 @@ extension PlatformWalletManager { await emitCoreWalletDiagnostics(for: walletId, checkpoint: .preExport) } + /// Coordinates the queue-owned SwiftData snapshot with read-only Rust FFI + /// queries. Admission happens after the database await, then keeps the + /// native handle alive until the off-main worker finishes. private func emitCoreWalletDiagnostics( for walletId: Data, checkpoint: CoreWalletDiagnosticCheckpoint @@ -1027,6 +1037,8 @@ extension PlatformWalletManager { } } + /// Runs all Rust-memory reads on `destroyQueue`. Each subsystem reports its + /// own unavailable state so one failed query does not hide the others. private nonisolated static func emitCoreMemoryDiagnostics( managerHandle: Handle, managedWallet: ManagedPlatformWallet?, @@ -1132,6 +1144,8 @@ extension PlatformWalletManager { ) } + /// Logs the deterministic DB↔Rust UTXO diff, excluding accounts whose Rust + /// UTXO query failed instead of falsely reporting all their rows DB-only. private nonisolated static func compareDatabase( _ database: CoreWalletDatabaseDiagnosticSnapshot, memoryTxos: [CoreWalletDatabaseDiagnosticSnapshot.Txo], @@ -1208,6 +1222,8 @@ extension PlatformWalletManager { ) } + /// Captures the managed wallet's tracked locks and compares them with the + /// queue-safe SwiftData snapshot. Raw outpoints are only reference-hashed. private nonisolated static func compareAssetLocks( _ database: CoreWalletDatabaseDiagnosticSnapshot, managedWallet: ManagedPlatformWallet?, @@ -1331,6 +1347,8 @@ extension PlatformWalletManager { } } + /// Copies the Rust-owned account-balance array into Swift values and frees + /// the FFI allocation on every successful non-empty path. private nonisolated static func diagnosticAccountBalances( managerHandle: Handle, walletId: Data @@ -1375,6 +1393,8 @@ extension PlatformWalletManager { }) } + /// Marshals one account selector, copies its Rust-owned UTXO slice, and + /// releases the native allocation after all pointed-to scripts are copied. private nonisolated static func diagnosticAccountUtxos( managerHandle: Handle, walletId: Data, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index ab4537caca0..a67fb23240b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -453,7 +453,18 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { txo.lastUpdated = Date() } } catch { - print("⚠️ persistAssetLocks: stale-TXO fetch failed for \(entry.outPointHex) — failing the round so the lock status does not commit ahead of its spend flags: \(error)") + SDKLogger.event( + "persistence_asset_lock_stale_txo_fetch_failed", + category: .persistence, + severity: .error, + fields: [ + "outpoint_reference": .referenceString(entry.outPointHex), + "status": .integer(Int64(entry.statusRaw)), + "wallet_reference": .reference(walletId), + ], + error: error, + redacting: [entry.outPointHex, wireTxid.hexString] + ) allPersisted = false } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift index c24f81295da..60dbbb93da1 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift @@ -31,15 +31,23 @@ import DashSDKFFI /// Serves every read live except the one model type it is told to fault, /// and records the reads it saw so a test can prove which fetch failed. private final class FetchFaultInjector: ModelFetching, @unchecked Sendable { - struct ReadFault: Error {} + struct ReadFault: LocalizedError { + let message: String + var errorDescription: String? { message } + } private let live = LiveModelFetcher() private let faulted: ObjectIdentifier + private let faultMessage: String private let lock = NSLock() private var reads: [String] = [] - init(faulting model: any PersistentModel.Type) { + init( + faulting model: any PersistentModel.Type, + faultMessage: String = "injected SwiftData read failure" + ) { faulted = ObjectIdentifier(model) + self.faultMessage = faultMessage } /// Model names in the order they were read, the faulted one included. @@ -56,7 +64,9 @@ private final class FetchFaultInjector: ModelFetching, @unchecked Sendable { lock.lock() reads.append(String(describing: T.self)) lock.unlock() - guard ObjectIdentifier(T.self) != faulted else { throw ReadFault() } + guard ObjectIdentifier(T.self) != faulted else { + throw ReadFault(message: faultMessage) + } return try live.fetch(descriptor, in: context) } } @@ -85,6 +95,16 @@ final class AssetLockSpendVisibilityTests: XCTestCase { super.tearDown() } + private func temporaryLogDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent( + "AssetLockSpendVisibilityTests-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: directory) } + return directory + } + // MARK: Fixtures /// Seeds the state an older build left behind: a restorable wallet @@ -269,19 +289,26 @@ final class AssetLockSpendVisibilityTests: XCTestCase { /// which rolls the status back and lets Rust re-emit it. func testPersistAssetLocksFailsTheRoundWhenTheStaleTxoFetchThrows() throws { let outpoint = try seedFundingTxoSpentByAMempoolAssetLock(into: container) - let injector = FetchFaultInjector(faulting: PersistentTxo.self) + let privacyTxid = Data((0..<32).map { UInt8($0) }) + let outPointRaw = PersistentTxo.makeOutpoint(txid: privacyTxid, vout: 0) + let outPointHex = PersistentAssetLock.encodeOutPoint(rawBytes: outPointRaw) + let injector = FetchFaultInjector( + faulting: PersistentTxo.self, + faultMessage: "failed for \(outPointHex), wire \(privacyTxid.hexString), at /private/user/wallet.sqlite" + ) let handler = PlatformWalletPersistenceHandler( modelContainer: container, network: .testnet, modelFetcher: injector ) - let outPointRaw = PersistentTxo.makeOutpoint(txid: lockTxid, vout: 0) + let session = try temporaryLogDirectory() + XCTAssertTrue(SDKLogger.installFileSink(at: session, includeDebug: false)) handler.beginChangeset(walletId: walletId) let staged = handler.persistAssetLocks( walletId: walletId, upserts: [.init( - outPointHex: PersistentAssetLock.encodeOutPoint(rawBytes: outPointRaw), + outPointHex: outPointHex, transactionBytes: Data(repeating: 0x05, count: 10), fundingTypeRaw: 0, identityIndexRaw: 0, @@ -312,6 +339,25 @@ final class AssetLockSpendVisibilityTests: XCTestCase { try txoIsSpent(outpoint: outpoint), "and nothing may be left half-applied by the rolled-back round" ) + + SDKLogger.flush() + let log = try String( + contentsOf: session.appendingPathComponent("swift/run.log"), + encoding: .utf8 + ) + let event = try XCTUnwrap(log.split(separator: "\n").first { + $0.contains("event=persistence_asset_lock_stale_txo_fetch_failed ") + }) + XCTAssertTrue(event.contains( + "outpoint_reference=\(SDKLogFormatter.reference(outPointHex))" + )) + XCTAssertTrue(event.contains("")) + XCTAssertFalse(event.contains(outPointHex)) + XCTAssertFalse(event.contains(privacyTxid.hexString)) + XCTAssertFalse(event.contains(Data(privacyTxid.reversed()).hexString)) + XCTAssertFalse(event.contains(outPointRaw.hexString)) + XCTAssertFalse(event.contains(walletId.hexString)) + XCTAssertFalse(event.contains("wallet.sqlite")) } /// An unreadable lock table must not be read as the positive claim diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticsTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticsTests.swift index 012c7de191f..3e51d237ba7 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticsTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticsTests.swift @@ -152,15 +152,16 @@ final class CoreWalletDiagnosticsTests: XCTestCase { ) } - func testCoinJoinSpendWithMissingBip44ChangeDetects4438AndLogIsPrivate() throws { + func testCoinJoinSpendWithMissingBip44ChangeDetects4438AndLogIsPrivate() async throws { let fixture = try makeMissingOwnedOutputFixture() let session = try temporaryDirectory() XCTAssertTrue(SDKLogger.installFileSink(at: session, includeDebug: false)) - XCTAssertNotNil(fixture.handler.emitCoreWalletDatabaseDiagnostics( + let databaseSnapshot = await fixture.handler.emitCoreWalletDatabaseDiagnostics( walletId: walletId, checkpoint: .preExport - )) + ) + XCTAssertNotNil(databaseSnapshot) let summaries = try logLines(in: session, event: "core_owned_output_audit_summary") let summary = try XCTUnwrap(summaries.last) @@ -207,7 +208,7 @@ final class CoreWalletDiagnosticsTests: XCTestCase { }.joined())) } - func testPersistedBip44ChangeClears4438Alarm() throws { + func testPersistedBip44ChangeClears4438Alarm() async throws { let fixture = try makeMissingOwnedOutputFixture() let output = fixture.decoded.outputs[0] let change = PersistentTxo( @@ -227,10 +228,11 @@ final class CoreWalletDiagnosticsTests: XCTestCase { let session = try temporaryDirectory() XCTAssertTrue(SDKLogger.installFileSink(at: session, includeDebug: false)) - XCTAssertNotNil(fixture.handler.emitCoreWalletDatabaseDiagnostics( + let databaseSnapshot = await fixture.handler.emitCoreWalletDatabaseDiagnostics( walletId: walletId, checkpoint: .preExport - )) + ) + XCTAssertNotNil(databaseSnapshot) let summaries = try logLines(in: session, event: "core_owned_output_audit_summary") let summary = try XCTUnwrap(summaries.last) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PlatformWalletShutdownTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PlatformWalletShutdownTests.swift index 9e43267fbfc..fedd8bbaf0c 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PlatformWalletShutdownTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PlatformWalletShutdownTests.swift @@ -177,6 +177,29 @@ final class PlatformWalletShutdownTests: XCTestCase { XCTAssertEqual(recorder.names, Self.expectedOrder) } + func testUnbalancedDiagnosticsFinishCannotUnderflowShutdownCounter() async throws { + let recorder = TeardownRecorder() + let manager = PlatformWalletManager.makeForTesting( + handle: 20, + calls: Self.makeCalls(recorder: recorder) + ) + + // A stray release at zero must be ignored. Without the guard this + // makes the next admission look idle and lets shutdown take its handle. + manager.finishCoreDiagnosticsNativeOp() + try manager.admitCoreDiagnosticsNativeOp() + + let shutdownTask = Task { await manager.shutdown() } + try await Task.sleep(for: .milliseconds(20)) + XCTAssertEqual(manager.handle, 20) + XCTAssertTrue(recorder.names.isEmpty) + + manager.finishCoreDiagnosticsNativeOp() + let metrics = await shutdownTask.value + XCTAssertEqual(metrics.steps.map(\.name), Self.expectedOrder) + XCTAssertEqual(recorder.names, Self.expectedOrder) + } + /// A completed real shutdown makes this manager terminal. Reconfiguration /// must fail before another native handle or callback context is installed. func testConfigurationAfterRealShutdownIsRejected() async { From 33a7f2b12dc77d9917bf8519f87a3aa1ef7118e0 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 7 Sep 2026 09:47:48 +0200 Subject: [PATCH 04/17] fix(swift-sdk): address core wallet diagnostics review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness of what the export claims: - Only report `unspent_with_confirmed_spending_transaction`. A TXO linked to a mempool spender while still unspent is what `reconcileSpendObservation` writes for every normal in-flight send, so the old rule put one warning per output on a healthy wallet. - Match CoinJoin TXOs to the wallet the way the rest of the snapshot does (denormalized id OR relationship). Accepting only the relationship dropped exactly the rows whose relationship is corrupt. - Count outputs the #4438 audit cannot attribute (`unattributed_output_count`, `output_address_undecodable_count`, `bip44_address_pool_size`) so a zero missing count is no longer read as proof for a wallet whose address rows are absent. - Resolve duplicate outpoints deterministically instead of `rows.first`, which made `wrong_wallet` depend on SwiftData's fetch order. - Emit `asset_lock_db_memory_diff_summary` with `diff_incomplete=true` when the Rust side fails, mirroring the database-unavailable path; an absent line is indistinguishable from a truncated log. Adds `memory_query_available` to all three paths. - Key the memory side of the AssetLock diff through `PersistentAssetLock.encodeOutPoint` rather than a second hex loop. - Report `unknown_previous_height` when the rescan checkpoint could not be read, and fold `requested >= previous` into `no_op` as `spvRescanFilters` documents. Store opening: - `DashModelContainer.open` falls back to inferred lightweight migration when the staged plan rejects the store. Only `PersistentAssetLock` is frozen so far, so a v4.2.0-dev.1 store matches no registered version and hosts turn the throw into a launch crash. Records the outcome as `migration_path` plus `core_store_staged_migration_failed`. - Buffer up to 256 events emitted before the log sink exists and replay them on install. `core_store_open_result` runs in the host's `init()` and never reached the exported `swift/run.log`. Cost on the launch path: - `summarizeRestoreBuffer` is one pass over counters instead of ~15 full array passes, and no longer retains the emitted-candidate array. - Keep only account-less rows in a side map rather than duplicating every unspent row into a second per-wallet bucket map. - Run read-only diagnostic FFI reads on their own `.utility` queue instead of the lifecycle `destroyQueue`. Tests and cleanup: - `Dev1StoreUpgradeTests` drives `DashModelContainer.open` — the path that ships — and asserts the staged plan alone still rejects the fixture, on its own copy. - The startup guard list uses the event names actually emitted; `core_db_memory_diff` never existed, so that guard could not fail. - Drop the hardcoded `container_reused`, and share one saturating-sum rule via `diagnosticSaturatingAdd`. Co-Authored-By: Claude Opus 5 --- .../Core/Services/SDKLogger.swift | 74 +++++++- .../Persistence/DashModelContainer.swift | 117 ++++++++---- .../CoreWalletDiagnosticAnalyzers.swift | 167 +++++++++++++----- .../PlatformWalletManager.swift | 14 ++ ...PlatformWalletManagerCoreDiagnostics.swift | 167 +++++++++++++++--- .../PlatformWalletManagerSPV.swift | 16 +- .../PlatformWalletPersistenceHandler.swift | 25 +-- .../CoreWalletDiagnosticAnalyzerTests.swift | 60 ++++++- .../CoreWalletDiagnosticsTests.swift | 29 +-- .../Dev1StoreUpgradeTests.swift | 56 +++--- .../SwiftDashSDKTests/Fixtures/README.md | 8 +- 11 files changed, 576 insertions(+), 157 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift index 3bbefb52c37..cc879c7d905 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift @@ -214,20 +214,47 @@ enum SDKLogFormatter { } private final class SDKLoggerState: @unchecked Sendable { + /// How many pre-install events are retained for replay. A host that never + /// installs a sink must not accumulate lines for the life of the process, + /// so the buffer drops its oldest entries and reports the loss instead. + private static let pendingLineLimit = 256 + private let lock = NSLock() private var sink: SDKLogFileSink? private var includeDebug = false - - func installSink(at sessionDirectory: URL, includeDebug: Bool) -> Bool { + /// Events emitted before the file sink exists. `DashModelContainer.create` + /// runs in the host's `init()`, long before `LoggingPreferences.configure()` + /// installs the sink, so without this buffer the store-open result — and + /// every other launch-path event — would only ever reach the console and + /// never the exported `swift/run.log`. + private var pendingLines: [(severity: SDKLogSeverity, line: String)] = [] + private var droppedPendingLineCount = 0 + + /// Installs the sink and replays what was emitted before it existed, in + /// emission order and under the sink's own debug filter. + func installSink(at sessionDirectory: URL, includeDebug: Bool) -> ( + installed: Bool, + droppedPendingLineCount: Int + ) { do { let newSink = try SDKLogFileSink(sessionDirectory: sessionDirectory) - lock.withLock { + // Replay under the same lock `record` takes, so a line emitted + // concurrently with the install cannot land in front of the + // backlog it actually followed. + let dropped: Int = lock.withLock { sink = newSink self.includeDebug = includeDebug + for entry in pendingLines where entry.severity != .debug || includeDebug { + newSink.write(entry.line) + } + let droppedCount = droppedPendingLineCount + pendingLines = [] + droppedPendingLineCount = 0 + return droppedCount } - return true + return (installed: true, droppedPendingLineCount: dropped) } catch { - return false + return (installed: false, droppedPendingLineCount: 0) } } @@ -237,11 +264,22 @@ private final class SDKLoggerState: @unchecked Sendable { } } - func destination(for severity: SDKLogSeverity) -> SDKLogFileSink? { - lock.withLock { + /// Routes one formatted line to the sink, or buffers it for replay when no + /// sink has been installed yet. + func record(severity: SDKLogSeverity, line: String) { + let destination: SDKLogFileSink? = lock.withLock { + guard let sink else { + if pendingLines.count >= Self.pendingLineLimit { + pendingLines.removeFirst() + droppedPendingLineCount += 1 + } + pendingLines.append((severity: severity, line: line)) + return nil + } guard severity != .debug || includeDebug else { return nil } return sink } + destination?.write(line) } func flush() { @@ -473,7 +511,7 @@ public enum SDKLogger { redacting: sensitiveValues ) - state.destination(for: severity)?.write(line) + state.record(severity: severity, line: line) let shouldMirrorToConsole: Bool switch severity { @@ -496,7 +534,25 @@ public enum SDKLogger { } static func installFileSink(at sessionDirectory: URL, includeDebug: Bool) -> Bool { - state.installSink(at: sessionDirectory, includeDebug: includeDebug) + let outcome = state.installSink( + at: sessionDirectory, + includeDebug: includeDebug + ) + if outcome.droppedPendingLineCount > 0 { + // Emitted after the replay so the gap is visible at the point in + // the file where the missing lines would have been. + event( + "log_pre_install_buffer_overflow", + category: .lifecycle, + severity: .warning, + fields: [ + "dropped_line_count": .integer( + Int64(outcome.droppedPendingLineCount) + ), + ] + ) + } + return outcome.installed } static func updateDebugSetting(_ includeDebug: Bool) { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index fb5c8d530d3..acee6f7d4fd 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -8,14 +8,22 @@ public enum DashModelContainer { let wal: UInt64 let shm: UInt64 + /// Shares the exporter's saturating rule so a corrupt size can never + /// trap and so both totals move together if that rule ever changes. var total: UInt64 { - [main, wal, shm].reduce(0) { partial, value in - let (sum, overflow) = partial.addingReportingOverflow(value) - return overflow ? UInt64.max : sum - } + diagnosticSaturatingSum([main, wal, shm]) } } + /// Which of the two open attempts produced the result being reported. + private enum StoreMigrationPath: String { + /// `DashMigrationPlan` accepted the store. + case staged + /// The staged plan rejected the store and SwiftData's inferred + /// lightweight migration was used instead — see `create`. + case inferredFallback = "inferred_fallback" + } + /// Builds the common payload for both sides of the container open. The /// outcome deliberately describes only what SwiftData tells us: opening an /// existing store may have included a migration, but this API does not @@ -23,6 +31,7 @@ public enum DashModelContainer { private static func storeOpenFields( succeeded: Bool, existedBefore: Bool, + migrationPath: StoreMigrationPath, startedAt: CFAbsoluteTime, sizeBefore: StoreFileSizes, sizeAfter: StoreFileSizes @@ -50,8 +59,8 @@ public enum DashModelContainer { return [ "container_result": .publicText(succeeded ? "opened" : "open_failed"), - "container_reused": .boolean(false), "duration_ms": .unsignedInteger(duration), + "migration_path": .publicText(migrationPath.rawValue), "result": .publicText(succeeded ? "success" : "failure"), "store_existed_before_open": .boolean(existedBefore), "store_main_size_bytes_after": .unsignedInteger(sizeAfter.main), @@ -169,14 +178,21 @@ public enum DashModelContainer { cloudKit: Bool = false, groupContainer: ModelConfiguration.GroupContainer = .automatic ) throws -> ModelContainer { - let modelConfiguration = ModelConfiguration( - schema: schema, - isStoredInMemoryOnly: false, - allowsSave: true, - groupContainer: groupContainer, - cloudKitDatabase: cloudKit ? .automatic : .none + return try open( + ModelConfiguration( + schema: schema, + isStoredInMemoryOnly: false, + allowsSave: true, + groupContainer: groupContainer, + cloudKitDatabase: cloudKit ? .automatic : .none + ) ) + } + /// The store-opening path every host goes through, parameterised on the + /// configuration only so a fixture store can exercise exactly what ships + /// (`Dev1StoreUpgradeTests`) instead of a look-alike built in the test. + static func open(_ modelConfiguration: ModelConfiguration) throws -> ModelContainer { // Always wire the migration plan so stores created by an older SDK // advance through the registered versioned schemas. Record only // metadata about the store — never its device path. @@ -184,42 +200,79 @@ public enum DashModelContainer { let existedBefore = FileManager.default.fileExists(atPath: storeURL.path) let sizeBefore = storeFileSizes(at: storeURL) let started = CFAbsoluteTimeGetCurrent() - do { - let container = try ModelContainer( - for: schema, - migrationPlan: DashMigrationPlan.self, - configurations: [modelConfiguration] - ) - let sizeAfter = storeFileSizes(at: storeURL) + + func report( + succeeded: Bool, + migrationPath: StoreMigrationPath, + error: Error? = nil + ) { SDKLogger.event( "core_store_open_result", category: .persistence, + severity: succeeded ? .info : .error, fields: storeOpenFields( - succeeded: true, + succeeded: succeeded, existedBefore: existedBefore, + migrationPath: migrationPath, startedAt: started, sizeBefore: sizeBefore, - sizeAfter: sizeAfter - ) + sizeAfter: storeFileSizes(at: storeURL) + ), + error: error, + redacting: [storeURL.path] ) + } + + do { + let container = try ModelContainer( + for: schema, + migrationPlan: DashMigrationPlan.self, + configurations: [modelConfiguration] + ) + report(succeeded: true, migrationPath: .staged) return container } catch { - let sizeAfter = storeFileSizes(at: storeURL) + // Staged migration matches a store by the CHECKSUM of each + // registered `VersionedSchema`, and only `PersistentAssetLock` is + // frozen so far (see `DashSchemaFrozenModels.swift`). Every other + // V1/V2 model is still referenced live, so a shape that has drifted + // since — `PersistentDocumentType` and `PersistentIndex` for the + // v4.2.0-dev.1 stores `Dev1StoreUpgradeTests` pins — leaves the + // real store matching no registered version, and the staged open + // fails with Cocoa 134504 rather than migrating. + // + // Hosts turn that throw into `fatalError` at launch, so retry the + // way they already open the store themselves: the current schema + // with SwiftData's inferred lightweight migration and no plan. + // This only ever runs after the staged attempt has already failed, + // and inference still throws when it cannot map the store, so the + // fallback can only turn a crash into a successful open — never + // widen the set of stores that are opened destructively. SDKLogger.event( - "core_store_open_result", + "core_store_staged_migration_failed", category: .persistence, - severity: .error, - fields: storeOpenFields( - succeeded: false, - existedBefore: existedBefore, - startedAt: started, - sizeBefore: sizeBefore, - sizeAfter: sizeAfter - ), + severity: .warning, + fields: [ + "store_existed_before_open": .boolean(existedBefore), + ], error: error, redacting: [storeURL.path] ) - throw error + do { + let container = try ModelContainer( + for: schema, + configurations: [modelConfiguration] + ) + report(succeeded: true, migrationPath: .inferredFallback) + return container + } catch let fallbackError { + report( + succeeded: false, + migrationPath: .inferredFallback, + error: fallbackError + ) + throw fallbackError + } } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWalletDiagnosticAnalyzers.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWalletDiagnosticAnalyzers.swift index ca9fa8bc0a3..b2955e42ef8 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWalletDiagnosticAnalyzers.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWalletDiagnosticAnalyzers.swift @@ -222,7 +222,7 @@ enum CoreWalletDiagnosticAnalyzer { let candidateCoinJoinCount: Int let candidateCoinJoinValueDuffs: UInt64 let builtCount: Int - let emittedCandidates: [RestoreCandidate] + let emittedCount: Int let emittedValueDuffs: UInt64 let emittedBip44Count: Int let emittedBip44ValueDuffs: UInt64 @@ -235,50 +235,105 @@ enum CoreWalletDiagnosticAnalyzer { /// Reconciles the candidate list with the compact FFI buffer length. An /// errored build reports zero emitted rows even if validation failed late. - static func summarizeRestoreBuffer( - candidates: [RestoreCandidate], + /// + /// Deliberately a single pass that accumulates into locals. This runs on + /// the launch restore path while the persistence queue is held, so a large + /// CoinJoin wallet must not pay for a dozen full-length `filter`/`map` + /// allocations, and nothing here retains a per-row array. + static func summarizeRestoreBuffer( + candidates: S, emittedCount: Int, errored: Bool - ) -> RestoreBufferSummary { - let valid = candidates.filter { $0.rejectionReason == nil } - let emittedCandidates = errored ? [] : Array(valid.prefix(max(0, emittedCount))) - let candidateBip44 = candidates.filter { - $0.accountType == 0 && $0.standardTag == 0 - } - let candidateCoinJoin = candidates.filter { $0.accountType == 1 } - let emittedBip44 = emittedCandidates.filter { - $0.accountType == 0 && $0.standardTag == 0 + ) -> RestoreBufferSummary where S.Element == RestoreCandidate { + // Rust is handed the first `emittedCount` rows that passed validation, + // in order; an errored build deallocated the whole buffer, so none of + // them reached it. + let emissionLimit = errored ? 0 : max(0, emittedCount) + + var candidateCount = 0 + var candidateValue: UInt64 = 0 + var candidateBip44Count = 0 + var candidateBip44Value: UInt64 = 0 + var candidateCoinJoinCount = 0 + var candidateCoinJoinValue: UInt64 = 0 + var emitted = 0 + var emittedValue: UInt64 = 0 + var emittedBip44Count = 0 + var emittedBip44Value: UInt64 = 0 + var emittedCoinJoinCount = 0 + var emittedCoinJoinValue: UInt64 = 0 + var missingAccountCount = 0 + var invalidTxidCount = 0 + var invalidAccountTypeCount = 0 + var validSeen = 0 + + for candidate in candidates { + candidateCount += 1 + let isBip44 = candidate.accountType == 0 && candidate.standardTag == 0 + let isCoinJoin = candidate.accountType == 1 + candidateValue = diagnosticSaturatingAdd(candidateValue, candidate.amount) + if isBip44 { + candidateBip44Count += 1 + candidateBip44Value = diagnosticSaturatingAdd( + candidateBip44Value, + candidate.amount + ) + } + if isCoinJoin { + candidateCoinJoinCount += 1 + candidateCoinJoinValue = diagnosticSaturatingAdd( + candidateCoinJoinValue, + candidate.amount + ) + } + + switch candidate.rejectionReason { + case .missingAccount: + missingAccountCount += 1 + case .invalidTxid: + invalidTxidCount += 1 + case .invalidAccountType: + invalidAccountTypeCount += 1 + case nil: + let emissionIndex = validSeen + validSeen += 1 + guard emissionIndex < emissionLimit else { continue } + emitted += 1 + emittedValue = diagnosticSaturatingAdd(emittedValue, candidate.amount) + if isBip44 { + emittedBip44Count += 1 + emittedBip44Value = diagnosticSaturatingAdd( + emittedBip44Value, + candidate.amount + ) + } + if isCoinJoin { + emittedCoinJoinCount += 1 + emittedCoinJoinValue = diagnosticSaturatingAdd( + emittedCoinJoinValue, + candidate.amount + ) + } + } } - let emittedCoinJoin = emittedCandidates.filter { $0.accountType == 1 } + return RestoreBufferSummary( - candidateCount: candidates.count, - candidateValueDuffs: diagnosticSaturatingSum(candidates.map(\.amount)), - candidateBip44Count: candidateBip44.count, - candidateBip44ValueDuffs: diagnosticSaturatingSum( - candidateBip44.map(\.amount) - ), - candidateCoinJoinCount: candidateCoinJoin.count, - candidateCoinJoinValueDuffs: diagnosticSaturatingSum( - candidateCoinJoin.map(\.amount) - ), + candidateCount: candidateCount, + candidateValueDuffs: candidateValue, + candidateBip44Count: candidateBip44Count, + candidateBip44ValueDuffs: candidateBip44Value, + candidateCoinJoinCount: candidateCoinJoinCount, + candidateCoinJoinValueDuffs: candidateCoinJoinValue, builtCount: emittedCount, - emittedCandidates: emittedCandidates, - emittedValueDuffs: diagnosticSaturatingSum(emittedCandidates.map(\.amount)), - emittedBip44Count: emittedBip44.count, - emittedBip44ValueDuffs: diagnosticSaturatingSum(emittedBip44.map(\.amount)), - emittedCoinJoinCount: emittedCoinJoin.count, - emittedCoinJoinValueDuffs: diagnosticSaturatingSum( - emittedCoinJoin.map(\.amount) - ), - missingAccountCount: candidates.filter { - $0.rejectionReason == .missingAccount - }.count, - invalidTxidCount: candidates.filter { - $0.rejectionReason == .invalidTxid - }.count, - invalidAccountTypeCount: candidates.filter { - $0.rejectionReason == .invalidAccountType - }.count + emittedCount: emitted, + emittedValueDuffs: emittedValue, + emittedBip44Count: emittedBip44Count, + emittedBip44ValueDuffs: emittedBip44Value, + emittedCoinJoinCount: emittedCoinJoinCount, + emittedCoinJoinValueDuffs: emittedCoinJoinValue, + missingAccountCount: missingAccountCount, + invalidTxidCount: invalidTxidCount, + invalidAccountTypeCount: invalidAccountTypeCount ) } @@ -289,6 +344,27 @@ enum CoreWalletDiagnosticAnalyzer { let walletIdMismatch: Bool let isSpent: Bool let hasSpendingTransaction: Bool + /// Whether the linked spending transaction has reached a confirmed + /// context. `nil` when there is no spending transaction to ask. + /// Load-bearing: a linked-but-unconfirmed spender with `isSpent == + /// false` is the normal in-flight send, not an anomaly. + let spendingTransactionIsInBlock: Bool? + + init( + txo: CoreWalletDatabaseDiagnosticSnapshot.Txo, + hasParentTransaction: Bool, + walletIdMismatch: Bool, + isSpent: Bool, + hasSpendingTransaction: Bool, + spendingTransactionIsInBlock: Bool? = nil + ) { + self.txo = txo + self.hasParentTransaction = hasParentTransaction + self.walletIdMismatch = walletIdMismatch + self.isSpent = isSpent + self.hasSpendingTransaction = hasSpendingTransaction + self.spendingTransactionIsInBlock = spendingTransactionIsInBlock + } } /// A database anomaly whose raw TXO identity is later hashed by the logger. @@ -329,10 +405,17 @@ enum CoreWalletDiagnosticAnalyzer { reason: "spent_without_spending_transaction" )) } - if !row.isSpent && row.hasSpendingTransaction { + // `reconcileSpendObservation` deliberately links a mempool + // spender while leaving `isSpent == false`, because a sighting + // alone is reversible by RBF or eviction. Only a spender that has + // landed in a block contradicts an unspent row; flagging the + // mempool case would put one warning per output on every wallet + // with an unconfirmed outgoing transaction and bury the real + // anomalies this export exists to surface. + if !row.isSpent && row.spendingTransactionIsInBlock == true { details.append(.init( txo: row.txo, - reason: "unspent_with_spending_transaction" + reason: "unspent_with_confirmed_spending_transaction" )) } if row.txo.outpoint.count != 36 { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 45060d28f02..e278aad7611 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -568,6 +568,20 @@ public class PlatformWalletManager: ObservableObject { qos: .userInitiated ) + /// Queue for the read-only Core diagnostic FFI reads. Deliberately NOT + /// [`destroyQueue`]: those reads are neither lifecycle operations nor + /// short, and putting them on the lifecycle queue would make one export + /// delay every manager's create/teardown, while a slow create or destroy + /// elsewhere would stall the diagnostics continuation — holding + /// `activeCoreDiagnosticsNativeOpCount` and so the next `shutdown()` drain. + /// Handle safety comes from that admission counter, not from FIFO ordering + /// with teardown, so a separate queue costs nothing. `.utility` because a + /// support export must never outrank the user's own wallet work. + nonisolated static let coreDiagnosticsQueue = DispatchQueue( + label: "org.dash.platform-wallet.core-diagnostics", + qos: .utility + ) + // MARK: - Init /// Empty init for `@StateObject` usage. Call [`configure`] before diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift index 1a7bc2e7c09..32a901c77aa 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift @@ -106,13 +106,17 @@ private extension Data { } } +/// Adds one diagnostic value without allowing corrupt data to trap the +/// exporter. The single saturating rule every diagnostic total shares. +func diagnosticSaturatingAdd(_ partial: UInt64, _ value: UInt64) -> UInt64 { + let (sum, overflow) = partial.addingReportingOverflow(value) + return overflow ? UInt64.max : sum +} + /// Adds diagnostic values without allowing corrupt data to trap the exporter. func diagnosticSaturatingSum(_ values: S) -> UInt64 where S.Element == UInt64 { - values.reduce(0) { partial, value in - let (sum, overflow) = partial.addingReportingOverflow(value) - return overflow ? UInt64.max : sum - } + values.reduce(0, diagnosticSaturatingAdd) } private func diagnosticSignedSaturatingSum(_ values: S) -> Int64 @@ -502,13 +506,23 @@ extension PlatformWalletPersistenceHandler { /// Logs the exact UTXO slice handed to Rust, independently of the broader /// database snapshot. This sits after compact-write, so `emitted_count` /// cannot be confused with the number of fetched candidates. + /// + /// - Parameters: + /// - rows: the rows the restore buffer was built from, in build order. + /// - accountLessRows: rows the restore fetch matched to this wallet by its + /// denormalized id but which carry no account, so they never reached FFI + /// marshalling. Passed separately rather than merged upstream: they are + /// rare, and keeping them out of `rows` avoids a second full-length copy + /// of every unspent row on the launch path. func logCoreRestoreBufferSnapshotOnQueue( walletId: Data, rows: [PersistentTxo], + accountLessRows: [PersistentTxo] = [], emittedCount: Int, errored: Bool ) { - let candidates = rows.map { row in + func candidate(_ row: PersistentTxo) + -> CoreWalletDiagnosticAnalyzer.RestoreCandidate { let rejection: CoreWalletDiagnosticAnalyzer.RestoreCandidate.RejectionReason? if row.account == nil { rejection = .missingAccount @@ -527,6 +541,11 @@ extension PlatformWalletPersistenceHandler { rejectionReason: rejection ) } + // Lazily, and with the built rows first: the summary's emission window + // is positional, so the rejected rows must not shift it. Nothing here + // materializes a per-row array — this runs while the launch restore + // holds the persistence queue. + let candidates = [rows, accountLessRows].lazy.flatMap { $0 }.map(candidate) // A validation error deallocates the compact buffer and aborts the // whole callback, so zero rows were actually handed to Rust even if // some valid rows preceded the corrupt one. @@ -556,7 +575,7 @@ extension PlatformWalletPersistenceHandler { "candidate_value_duffs": .unsignedInteger(summary.candidateValueDuffs), "built_count": .integer(Int64(summary.builtCount)), "checkpoint": .publicText(CoreWalletDiagnosticCheckpoint.restoreBuffer.rawValue), - "emitted_count": .integer(Int64(summary.emittedCandidates.count)), + "emitted_count": .integer(Int64(summary.emittedCount)), "emitted_bip44_count": .integer(Int64(summary.emittedBip44Count)), "emitted_bip44_value_duffs": .unsignedInteger( summary.emittedBip44ValueDuffs @@ -624,7 +643,8 @@ extension PlatformWalletPersistenceHandler { && relationshipWalletId != nil && txo.walletId != relationshipWalletId, isSpent: txo.isSpent, - hasSpendingTransaction: txo.spendingTransaction != nil + hasSpendingTransaction: txo.spendingTransaction != nil, + spendingTransactionIsInBlock: txo.spendingTransaction.map(spendIsInBlock) ) }) SDKLogger.event( @@ -645,7 +665,7 @@ extension PlatformWalletPersistenceHandler { )), "spent_relation_mismatch_count": .integer(Int64( result.count(reason: "spent_without_spending_transaction") - + result.count(reason: "unspent_with_spending_transaction") + + result.count(reason: "unspent_with_confirmed_spending_transaction") )), "truncated_count": .integer(Int64(result.truncatedCount)), "wallet_mismatch_count": .integer(Int64( @@ -675,6 +695,13 @@ extension PlatformWalletPersistenceHandler { /// transaction's persisted role: it decodes inputs, proves at least one /// spends a known CoinJoin TXO, then checks every decoded output against /// the persisted BIP44 address pool and the TXO table. + /// + /// Ownership is decided by the persisted `PersistentCoreAddress` pool, so + /// `coinjoin_to_bip44_missing_count == 0` proves only that every output the + /// audit could attribute is persisted. An output beyond the derived pool, + /// or one whose address row was never written, is attributable to nobody + /// and lands in `unattributed_output_count` instead — which is why the + /// summary reports that counter and the pool size next to the verdict. private static func auditCoinJoinOwnedBip44Outputs( wallet: PersistentWallet, walletId: Data, @@ -682,9 +709,13 @@ extension PlatformWalletPersistenceHandler { allTxos: [PersistentTxo], allTransactions: [PersistentTransaction] ) { + // Match the wallet exactly as `walletTxos` does. Accepting only the + // relationship would drop a CoinJoin row whose `account.wallet` link is + // broken — the very corruption this audit exists to expose — and its + // spending transaction would never even become a candidate. let coinJoinOutpoints = Set(allTxos.compactMap { txo -> Data? in - guard relationshipWalletId(of: txo) == walletId, - txo.account?.accountType == 1 + guard txo.account?.accountType == 1, + txo.walletId == walletId || relationshipWalletId(of: txo) == walletId else { return nil } return txo.outpoint }) @@ -700,6 +731,8 @@ extension PlatformWalletPersistenceHandler { var decodeFailureCount = 0 var ownedOutputCount = 0 var ownedOutputValue: UInt64 = 0 + var unattributedOutputCount = 0 + var undecodableAddressOutputCount = 0 var validCount = 0 var anomalies: [(tx: PersistentTransaction, vout: UInt32, amount: UInt64, outpoint: Data, reason: String)] = [] @@ -740,15 +773,27 @@ extension PlatformWalletPersistenceHandler { candidateCount += 1 for (index, output) in decoded.outputs.enumerated() { - guard let address = output.address, - let expectedAccount = bip44Addresses[address] - else { continue } + guard let address = output.address else { + // Non-P2PKH/P2SH scriptPubKey: nothing to match against the + // address pool, so it is unclassified rather than foreign. + undecodableAddressOutputCount += 1 + continue + } + guard let expectedAccount = bip44Addresses[address] else { + // Either a genuine payment to someone else or one of our + // own change addresses with no persisted row. The audit + // cannot tell them apart, so it counts rather than clears. + unattributedOutputCount += 1 + continue + } ownedOutputCount += 1 - let (newValue, overflow) = ownedOutputValue.addingReportingOverflow(output.valueDuffs) - ownedOutputValue = overflow ? UInt64.max : newValue + ownedOutputValue = diagnosticSaturatingAdd(ownedOutputValue, output.valueDuffs) let vout = UInt32(index) let outpoint = PersistentTxo.makeOutpoint(txid: decoded.txid, vout: vout) - guard let rows = txoByOutpoint[outpoint], let row = rows.first else { + guard let row = representativeTxo( + rows: txoByOutpoint[outpoint], + walletId: walletId + ) else { anomalies.append((transaction, vout, output.valueDuffs, outpoint, "missing_txo")) continue } @@ -797,16 +842,21 @@ extension PlatformWalletPersistenceHandler { severity: anomalies.isEmpty && decodeFailureCount == 0 ? .info : .warning, fields: [ "audit_incomplete": .boolean(decodeFailureCount > 0), + "bip44_address_pool_size": .integer(Int64(bip44Addresses.count)), "candidate_transaction_count": .integer(Int64(candidateCount)), "checkpoint": .publicText(checkpoint.rawValue), "coinjoin_to_bip44_missing_count": .integer(Int64(missingCount)), "coinjoin_to_bip44_missing_value_duffs": .unsignedInteger(missingValue), "decode_failure_count": .integer(Int64(decodeFailureCount)), + "output_address_undecodable_count": .integer( + Int64(undecodableAddressOutputCount) + ), "owned_bip44_output_count": .integer(Int64(ownedOutputCount)), "owned_bip44_output_value_duffs": .unsignedInteger(ownedOutputValue), "persisted_valid_count": .integer(Int64(validCount)), "total_anomaly_count": .integer(Int64(anomalies.count)), "truncated_count": .integer(Int64(truncatedAnomalyCount)), + "unattributed_output_count": .integer(Int64(unattributedOutputCount)), "wallet_reference": .reference(walletId), ] ) @@ -834,6 +884,46 @@ extension PlatformWalletPersistenceHandler { } } + /// Picks the row that represents one outpoint when the table holds more + /// than one. + /// + /// A duplicated outpoint split across wallets is precisely the `wrong_wallet` + /// corruption this audit names, so the choice must not depend on SwiftData's + /// fetch order — the same database would otherwise report `wrong_wallet` on + /// one run and a clean count on the next. A row this wallet owns wins (the + /// output IS persisted here, whatever else shares the outpoint); otherwise a + /// deterministic representative is chosen the way `compareTxos` resolves + /// duplicates before comparing. + private static func representativeTxo( + rows: [PersistentTxo]?, + walletId: Data + ) -> PersistentTxo? { + guard let rows, !rows.isEmpty else { return nil } + if rows.count == 1 { return rows[0] } + let ordered = rows.sorted { + duplicateResolutionKey($0).lexicographicallyPrecedes( + duplicateResolutionKey($1) + ) + } + return ordered.first { + relationshipWalletId(of: $0) == walletId + && ($0.walletId.isEmpty || $0.walletId == walletId) + } ?? ordered[0] + } + + /// Total order over rows sharing an outpoint. Uses only persisted bytes, so + /// two runs over the same database agree. + private static func duplicateResolutionKey(_ txo: PersistentTxo) -> Data { + var key = Data() + key.append(txo.walletId) + key.append(0) + key.append(relationshipWalletId(of: txo) ?? Data()) + key.append(0) + withUnsafeBytes(of: txo.amount.littleEndian) { key.append(contentsOf: $0) } + key.append(txo.scriptPubKey) + return key + } + private static func logAssetLockDatabaseSnapshot( context: ModelContext, walletId: Data, @@ -1025,7 +1115,7 @@ extension PlatformWalletManager { let managerHandle = handle let managedWallet = wallets[walletId] await withCheckedContinuation { continuation in - Self.destroyQueue.async { + Self.coreDiagnosticsQueue.async { Self.emitCoreMemoryDiagnostics( managerHandle: managerHandle, managedWallet: managedWallet, @@ -1037,8 +1127,9 @@ extension PlatformWalletManager { } } - /// Runs all Rust-memory reads on `destroyQueue`. Each subsystem reports its - /// own unavailable state so one failed query does not hide the others. + /// Runs all Rust-memory reads on `coreDiagnosticsQueue`. Each subsystem + /// reports its own unavailable state so one failed query does not hide the + /// others. private nonisolated static func emitCoreMemoryDiagnostics( managerHandle: Handle, managedWallet: ManagedPlatformWallet?, @@ -1246,6 +1337,24 @@ extension PlatformWalletManager { "wallet_reference": .reference(database.walletId), ] ) + // Mirror the database-unavailable path below: an analyst greps for + // `asset_lock_db_memory_diff_summary`, and a missing line is + // indistinguishable from a truncated log. Say the diff is + // incomplete instead of saying nothing. + SDKLogger.event( + "asset_lock_db_memory_diff_summary", + category: .persistence, + severity: .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "database_query_available": .boolean(database.assetLocksAvailable), + "diff_incomplete": .boolean(true), + "memory_query_available": .boolean(false), + "mismatch_count": .integer(0), + "truncated_count": .integer(0), + "wallet_reference": .reference(database.walletId), + ] + ) return } SDKLogger.event( @@ -1308,6 +1417,7 @@ extension PlatformWalletManager { "checkpoint": .publicText(checkpoint.rawValue), "database_query_available": .boolean(false), "diff_incomplete": .boolean(true), + "memory_query_available": .boolean(true), "mismatch_count": .integer(0), "truncated_count": .integer(0), "wallet_reference": .reference(database.walletId), @@ -1327,6 +1437,7 @@ extension PlatformWalletManager { "checkpoint": .publicText(checkpoint.rawValue), "database_query_available": .boolean(true), "diff_incomplete": .boolean(false), + "memory_query_available": .boolean(true), "mismatch_count": .integer(Int64(result.details.count)), "truncated_count": .integer(Int64(result.truncatedCount)), "wallet_reference": .reference(database.walletId), @@ -1469,11 +1580,25 @@ extension PlatformWalletManager { ) } + /// Renders the memory side of the AssetLock diff in the exact format the + /// database side is keyed by. + /// + /// `compareAssetLocks` matches the two sides on this string, so it must go + /// through `PersistentAssetLock.encodeOutPoint` rather than a second hex + /// loop: a hand-rolled copy agrees only by coincidence, and any later change + /// to the canonical encoder would silently make every lock report as both + /// `database_only` and `memory_only`. private nonisolated static func assetLockOutpointDisplay( txid: Data, vout: UInt32 ) -> String { - let display = txid.reversed().map { String(format: "%02x", $0) }.joined() - return "\(display):\(vout)" + let raw = PersistentTxo.makeOutpoint(txid: txid, vout: vout) + // `encodeOutPoint` traps on a malformed outpoint. Diagnostics must + // survive corrupt input, so fall back to a clearly non-matching marker + // that shows up as `memory_only` instead of taking the process down. + guard raw.count == 36 else { + return "invalid_outpoint:\(raw.count)_bytes:\(vout)" + } + return PersistentAssetLock.encodeOutPoint(rawBytes: raw) } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swift index b38f4a0a37b..97a777801b8 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swift @@ -92,21 +92,25 @@ public struct PlatformSpvSyncProgress: Sendable, Equatable { } enum CoreRescanDiagnosticResult: String, Sendable, Equatable { + /// The request lowered the checkpoint, so the filter sync will rescan. case armed - case acceptedNoRewind = "accepted_no_rewind" + /// The request was at or above the checkpoint: stored, but no rescan (see + /// ``PlatformWalletManager/spvRescanFilters(walletId:fromHeight:)``). case noOp = "no_op" + /// The checkpoint could not be read, so nothing about a rewind is known. + case unknownPreviousHeight = "unknown_previous_height" } /// Classifies only what can be proven from the checkpoint visible before the -/// accepted FFI call. A missing checkpoint is not evidence of a rewind. +/// accepted FFI call. A missing checkpoint is not evidence of a rewind: without +/// it, an analyst must not be able to read the log as ruling one out, which is +/// what any positive label would invite. func coreRescanDiagnosticResult( previousSyncedHeight: UInt32?, requestedStartHeight: UInt32 ) -> CoreRescanDiagnosticResult { - guard let previousSyncedHeight else { return .acceptedNoRewind } - if requestedStartHeight < previousSyncedHeight { return .armed } - if requestedStartHeight == previousSyncedHeight { return .noOp } - return .acceptedNoRewind + guard let previousSyncedHeight else { return .unknownPreviousHeight } + return requestedStartHeight < previousSyncedHeight ? .armed : .noOp } /// Node type of a connected SPV peer, classified against the masternode diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index a67fb23240b..5837db96d31 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -1392,7 +1392,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// persisted state reflects "still spendable from this row's POV", /// and the catch-up classifier on the next launch reloads the /// row and recognises it as ours when the block arrives. - private static func spendIsInBlock(_ tx: PersistentTransaction) -> Bool { + /// Internal rather than private so the read-only diagnostics extension + /// classifies a linked spender by the same rule that decided `isSpent`. + static func spendIsInBlock(_ tx: PersistentTransaction) -> Bool { tx.context >= TransactionContextType.inBlock.rawValue } @@ -5256,10 +5258,12 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // called on the path through `loadAllocations` after the // pointer hand-off to Rust succeeds). var unspentBuckets: [Data: [PersistentTxo]] = [:] - // Mirrors the already-required restore fetch, but retains rows that - // have a denormalized wallet id and no account so the lightweight - // restore summary can report why they were not handed to Rust. - var restoreDiagnosticBuckets: [Data: [PersistentTxo]] = [:] + // Rows the restore fetch matched to a wallet but which carry no + // account, so they can never be marshalled. Kept apart from + // `unspentBuckets` — rather than duplicating every unspent row into a + // second map — so the lightweight restore summary can report why they + // were dropped at no cost to the launch path. + var accountLessBuckets: [Data: [PersistentTxo]] = [:] do { var unspentDescriptor = FetchDescriptor( predicate: #Predicate { $0.isSpent == false } @@ -5365,7 +5369,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } unspentBuckets.reserveCapacity(restorable.count) - restoreDiagnosticBuckets.reserveCapacity(restorable.count) for row in liveUnspent { let key: Data if !row.walletId.isEmpty { @@ -5381,11 +5384,13 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } else { continue } - restoreDiagnosticBuckets[key, default: []].append(row) // Preserve the upstream restore contract: account-less rows // are diagnostic candidates only and never enter FFI // marshalling. - guard row.account != nil else { continue } + guard row.account != nil else { + accountLessBuckets[key, default: []].append(row) + continue + } unspentBuckets[key, default: []].append(row) } } @@ -5607,14 +5612,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // account isn't a funds variant get silently skipped on // the receiving side. let restoreRows = unspentBuckets[w.walletId] ?? [] - let diagnosticRows = restoreDiagnosticBuckets[w.walletId] ?? restoreRows let (utxoBuf, utxoCount, utxoErrored) = buildUtxoRestoreBuffer( rows: restoreRows, allocation: allocation ) logCoreRestoreBufferSnapshotOnQueue( walletId: w.walletId, - rows: diagnosticRows, + rows: restoreRows, + accountLessRows: accountLessBuckets[w.walletId] ?? [], emittedCount: utxoCount, errored: utxoErrored ) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticAnalyzerTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticAnalyzerTests.swift index c38f9569dc2..281f1886458 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticAnalyzerTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticAnalyzerTests.swift @@ -225,9 +225,57 @@ final class CoreWalletDiagnosticAnalyzerTests: XCTestCase { XCTAssertEqual(summary.candidateCount, 2) XCTAssertEqual(summary.candidateValueDuffs, 1_500) XCTAssertEqual(summary.missingAccountCount, 1) - XCTAssertEqual(summary.emittedCandidates.count, 1) - XCTAssertEqual(summary.emittedCandidates.first?.amount, acceptedTxo.amount) + XCTAssertEqual(summary.emittedCount, 1) XCTAssertEqual(summary.emittedValueDuffs, 800) + XCTAssertEqual(summary.emittedBip44Count, 1) + XCTAssertEqual(summary.emittedBip44ValueDuffs, 800) + } + + /// A TXO linked to a mempool spender while still unspent is what + /// `reconcileSpendObservation` writes for every normal in-flight send, so + /// it must not be reported — otherwise one healthy unconfirmed transaction + /// buries the export in warnings. + func testUnconfirmedSpenderIsNotAnAnomalyButAConfirmedOneIs() { + let inFlightSend = CoreWalletDiagnosticAnalyzer.databaseTxoAnomalies([ + .init( + txo: txo(0x32), + hasParentTransaction: true, + walletIdMismatch: false, + isSpent: false, + hasSpendingTransaction: true, + spendingTransactionIsInBlock: false + ), + ]) + XCTAssertTrue(inFlightSend.details.isEmpty) + + let confirmedButUnspent = CoreWalletDiagnosticAnalyzer.databaseTxoAnomalies([ + .init( + txo: txo(0x33), + hasParentTransaction: true, + walletIdMismatch: false, + isSpent: false, + hasSpendingTransaction: true, + spendingTransactionIsInBlock: true + ), + ]) + XCTAssertEqual( + confirmedButUnspent.count(reason: "unspent_with_confirmed_spending_transaction"), + 1 + ) + + let spentWithoutLink = CoreWalletDiagnosticAnalyzer.databaseTxoAnomalies([ + .init( + txo: txo(0x34), + hasParentTransaction: true, + walletIdMismatch: false, + isSpent: true, + hasSpendingTransaction: false + ), + ]) + XCTAssertEqual( + spentWithoutLink.count(reason: "spent_without_spending_transaction"), + 1 + ) } func testShieldedStoreSummaryIncludesValuesActivityKeysAndWatermark() { @@ -293,19 +341,23 @@ final class CoreWalletDiagnosticAnalyzerTests: XCTestCase { ), .noOp ) + // Above the checkpoint is stored but arms nothing, exactly like the + // equal case — see `spvRescanFilters`. XCTAssertEqual( coreRescanDiagnosticResult( previousSyncedHeight: 2_480_000, requestedStartHeight: 2_484_000 ), - .acceptedNoRewind + .noOp ) + // No checkpoint was readable, so the log must not let an analyst rule + // a rewind in or out. XCTAssertEqual( coreRescanDiagnosticResult( previousSyncedHeight: nil, requestedStartHeight: 2_484_000 ), - .acceptedNoRewind + .unknownPreviousHeight ) } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticsTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticsTests.swift index 3e51d237ba7..32eb10afcd1 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticsTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticsTests.swift @@ -313,22 +313,31 @@ final class CoreWalletDiagnosticsTests: XCTestCase { contentsOf: session.appendingPathComponent("swift/run.log"), encoding: .utf8 ) - let deepStartupEvents = [ - "core_db_wallet_snapshot", + // The complete set of events the pre-export path emits, spelled exactly + // as `PlatformWalletManagerCoreDiagnostics` writes them — a name that + // is never emitted (`core_db_memory_diff`, say, whose real event is + // `core_db_memory_diff_item`) would make its guard vacuous. + let deepDiagnosticEvents = [ + "asset_lock_db_group", + "asset_lock_db_memory_diff_item", + "asset_lock_db_memory_diff_summary", + "asset_lock_db_snapshot", + "asset_lock_memory_group", + "asset_lock_memory_snapshot", "core_db_account_snapshot", "core_db_anomaly_summary", + "core_db_memory_diff_item", + "core_db_memory_diff_summary", "core_db_txo_anomaly", - "core_owned_output_audit_summary", + "core_db_wallet_snapshot", + "core_diagnostics_unavailable", + "core_memory_account_snapshot", + "core_memory_snapshot_unavailable", "core_owned_output_anomaly", - "asset_lock_db_snapshot", + "core_owned_output_audit_summary", "shielded_store_snapshot", - "core_memory_account_snapshot", - "core_db_memory_diff_summary", - "core_db_memory_diff", - "asset_lock_memory_snapshot", - "asset_lock_db_memory_diff_summary", ] - for event in deepStartupEvents { + for event in deepDiagnosticEvents { XCTAssertFalse(completeLog.contains("event=\(event) "), event) } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift index 978f4980349..672248b788f 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift @@ -4,19 +4,21 @@ import XCTest @testable import SwiftDashSDK -/// Pins the store-opening semantics used by DashWallet's -/// `SwiftDashSDKHost.buildModelContainer`: the current schema with inferred -/// lightweight migration and no staged migration plan. +/// Pins that a real v4.2.0-dev.1 store opens through the path the SDK actually +/// ships, and keeps its Core wallet records. /// -/// `DashModelContainer.create` currently supplies `DashMigrationPlan` and -/// rejects the real v4.2.0-dev.1 checksum with Cocoa error 134504 because the -/// historical `PersistentDocumentType` and `PersistentIndex` shapes are not -/// registered as a frozen schema. This test deliberately does not exercise -/// that known-broken factory path; it verifies that the app-compatible path -/// opens the old store and preserves its Core wallet records. +/// The staged `DashMigrationPlan` alone cannot open it: staged migration +/// matches a store by each registered `VersionedSchema`'s checksum, and only +/// `PersistentAssetLock` is frozen so far (`DashSchemaFrozenModels.swift`), so +/// the drifted `PersistentDocumentType` / `PersistentIndex` shapes leave a +/// dev.1 store matching no registered version — Cocoa error 134504. Hosts turn +/// that throw into a launch crash, which is why `DashModelContainer.open` falls +/// back to inferred lightweight migration. This test drives that production +/// entry point rather than rebuilding a look-alike container, so the fallback +/// cannot regress unnoticed. @MainActor final class Dev1StoreUpgradeTests: XCTestCase { - func testDev1StoreOpensWithoutStagedPlanAndPreservesCoreRows() throws { + func testDev1StoreOpensThroughProductionFactoryAndPreservesCoreRows() throws { let resourceURL = try XCTUnwrap( Bundle.module.url( forResource: "DashModel-v4.2.0-dev.1.sqlite", @@ -40,19 +42,31 @@ final class Dev1StoreUpgradeTests: XCTestCase { try? FileManager.default.removeItem(at: directory) } - let storeURL = directory.appendingPathComponent("DashModel.sqlite") - try sqlite.write(to: storeURL, options: .atomic) + func configuration(named name: String) throws -> ModelConfiguration { + let storeURL = directory.appendingPathComponent(name) + try sqlite.write(to: storeURL, options: .atomic) + return ModelConfiguration( + schema: DashModelContainer.schema, + url: storeURL, + allowsSave: true, + cloudKitDatabase: .none + ) + } - let schema = DashModelContainer.schema - let configuration = ModelConfiguration( - schema: schema, - url: storeURL, - allowsSave: true, - cloudKitDatabase: .none + // The staged plan on its own is what crashes hosts today. Assert it on + // its own copy of the fixture — a failed open must not be what the + // production path below is then handed — so this test keeps naming the + // cause once the remaining models are frozen and it starts succeeding. + XCTAssertThrowsError( + try ModelContainer( + for: DashModelContainer.schema, + migrationPlan: DashMigrationPlan.self, + configurations: [try configuration(named: "StagedOnly.sqlite")] + ) ) - let container = try ModelContainer( - for: schema, - configurations: [configuration] + + let container = try DashModelContainer.open( + try configuration(named: "DashModel.sqlite") ) let context = ModelContext(container) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/README.md b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/README.md index 7bded8cbd46..1838819f6e0 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/README.md +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/README.md @@ -9,5 +9,9 @@ material. - uncompressed SHA-256: `17c2e93e655b79c43d023f41a4a4360e511d8f97af56aedfce32bd73c0158e58` - compression: Foundation `NSData.CompressionAlgorithm.zlib` -The regression test opens a copy with the same inferred lightweight-migration -path used by DashWallet and verifies that the Core wallet records survive. +The regression test opens a copy through `DashModelContainer.open` — the same +entry point every host uses — and verifies that the Core wallet records survive. +It also asserts that the staged `DashMigrationPlan` alone still rejects this +store, which is the reason that entry point falls back to inferred lightweight +migration; see `DashSchemaFrozenModels.swift` for why the V1/V2 checksums no +longer match a dev.1 store. From e27c0249f4418e1bc0b4de332d6c8a8cc12dc5e3 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 7 Sep 2026 10:27:53 +0200 Subject: [PATCH 05/17] fix(swift-sdk): scope the migration fallback to the store's version, bound the export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 review of the Core wallet diagnostics. Store opening: - `DashModelContainer.open` falls back to inferred migration only when the store existed before the open AND matches no schema `DashMigrationPlan` registers. The decision is made on the store — `storeMatchesRegisteredSchema` reads its metadata through `NSPersistentStoreCoordinator` and checks each registered `VersionedSchema`'s `NSManagedObjectModel` for compatibility — because the error SwiftData throws for Cocoa 134504 is the opaque `SwiftDataError.loadIssueModelContainer` with no underlying `NSError`, the same value a corrupt file produces. A store that matches a registered version and still failed (a future custom `MigrationStage`) is rethrown untouched, so the fallback can never stamp the current checksum on a store that skipped a stage. Unreadable metadata is rethrown too. - `open(_:)` is public: DashWallet builds its own `ModelConfiguration` and never calls `create`, so without this none of the store-open telemetry or the fallback can reach it. - `Dev1StoreUpgradeTests` pins the precondition (the fixture matches no registered version), the fallback, the rethrow of a corrupt file with no fallback attempted, and the self-heal: after the fallback the store matches a registered version and reopens through the staged path. Export cost: - `CoreDiagnosticRowLimits`: the export counts rows before materializing. Above 100k TXO rows or 20k transaction rows table-wide it narrows to the wallet's own rows, declines the exact #4438 audit, and reports `audit_incomplete=true, reason=tables_too_large_for_exact_audit` with the counts and limits. Not a fetch limit — a truncated table would collapse `wrong_wallet` into `missing_txo` — but an honest refusal where the exact pass is not computable. `core_db_wallet_snapshot` records `txo_scan_scope`. - `emitCoreWalletDiagnostics(for:)` documents that it holds the persistence serial queue for its duration and blocks every Rust persister/SPV callback until it returns. The paged variant is #4607. Audit correctness: - The representative row is judged by the same rule that admitted it (`denormalized || relationship`). Ours by id with a nil link reports `relationship_missing`; ours by id with a link elsewhere reports `wallet_id_mismatch` — the vocabulary `logTxoAnomalies` already uses. `representativeTxo` prefers by the same rule. Tests: - `SDKLoggerPreInstallBufferTests`: replay order, debug filtering at replay, overflow drops the oldest and reports the count, second install replays nothing. On a fresh `SDKLoggerState`, since the singleton has no way back to "no sink". - DB/memory AssetLock key pairing through one encoder; malformed txid neither traps nor collides. - `representativeTxo` on transient rows (`outpoint` is unique, so a duplicate cannot be saved through a context). - Unattributed-output and undecodable-address counters; the broken-link classification. Co-Authored-By: Claude Fable 5.1 --- .../Core/Services/SDKLogger.swift | 7 +- .../Persistence/DashModelContainer.swift | 67 +++++- ...PlatformWalletManagerCoreDiagnostics.swift | 146 ++++++++++-- .../CoreWalletDiagnosticAnalyzerTests.swift | 36 +++ .../CoreWalletDiagnosticsTests.swift | 218 ++++++++++++++++++ .../Dev1StoreUpgradeTests.swift | 158 ++++++++++--- .../SDKLoggerPreInstallBufferTests.swift | 97 ++++++++ 7 files changed, 663 insertions(+), 66 deletions(-) create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SDKLoggerPreInstallBufferTests.swift diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift index cc879c7d905..1a83ad3ad25 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift @@ -213,11 +213,14 @@ enum SDKLogFormatter { } } -private final class SDKLoggerState: @unchecked Sendable { +/// Internal rather than private so the pre-install buffer can be tested on a +/// fresh instance: the process-wide `SDKLogger.state` has no way back to the +/// "no sink installed" condition once any test has installed one. +final class SDKLoggerState: @unchecked Sendable { /// How many pre-install events are retained for replay. A host that never /// installs a sink must not accumulate lines for the life of the process, /// so the buffer drops its oldest entries and reports the loss instead. - private static let pendingLineLimit = 256 + static let pendingLineLimit = 256 private let lock = NSLock() private var sink: SDKLogFileSink? diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index acee6f7d4fd..13402f0fb59 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -1,3 +1,4 @@ +import CoreData import Foundation import SwiftData @@ -24,6 +25,32 @@ public enum DashModelContainer { case inferredFallback = "inferred_fallback" } + /// Whether the store at `storeURL` was written by a schema that + /// `DashMigrationPlan` registers. + /// + /// This is the question staged migration asks and answers with Cocoa + /// 134504 ("Cannot use staged migration with an unknown model version") + /// when the answer is no. It has to be asked here directly, because the + /// error SwiftData surfaces for it is `SwiftDataError.loadIssueModelContainer` + /// with no explanation and no underlying `NSError` — the same value a + /// corrupt file produces — so nothing in the thrown error distinguishes the + /// one failure the fallback may answer from every failure it must not. + /// + /// Returns `nil` when the metadata cannot be read at all: that is not a + /// version question, and the caller treats it exactly like a match. + static func storeMatchesRegisteredSchema(at storeURL: URL) -> Bool? { + guard let metadata = try? NSPersistentStoreCoordinator.metadataForPersistentStore( + ofType: NSSQLiteStoreType, + at: storeURL, + options: nil + ) else { return nil } + return DashMigrationPlan.schemas.contains { schema in + guard let model = NSManagedObjectModel.makeManagedObjectModel(for: schema.models) + else { return false } + return model.isConfiguration(withName: nil, compatibleWithStoreMetadata: metadata) + } + } + /// Builds the common payload for both sides of the container open. The /// outcome deliberately describes only what SwiftData tells us: opening an /// existing store may have included a migration, but this API does not @@ -189,10 +216,15 @@ public enum DashModelContainer { ) } - /// The store-opening path every host goes through, parameterised on the - /// configuration only so a fixture store can exercise exactly what ships - /// (`Dev1StoreUpgradeTests`) instead of a look-alike built in the test. - static func open(_ modelConfiguration: ModelConfiguration) throws -> ModelContainer { + /// The instrumented store-opening path, parameterised on the configuration. + /// + /// Public so a host that builds its own `ModelConfiguration` — DashWallet + /// does, with a per-network URL — gets the same `core_store_open_result` + /// telemetry and the same narrowly-scoped migration fallback as `create`, + /// instead of a bare `ModelContainer(for:configurations:)` that reports + /// nothing. It is also what lets `Dev1StoreUpgradeTests` drive exactly the + /// path that ships against a fixture store. + public static func open(_ modelConfiguration: ModelConfiguration) throws -> ModelContainer { // Always wire the migration plan so stores created by an older SDK // advance through the registered versioned schemas. Record only // metadata about the store — never its device path. @@ -241,13 +273,26 @@ public enum DashModelContainer { // real store matching no registered version, and the staged open // fails with Cocoa 134504 rather than migrating. // - // Hosts turn that throw into `fatalError` at launch, so retry the - // way they already open the store themselves: the current schema - // with SwiftData's inferred lightweight migration and no plan. - // This only ever runs after the staged attempt has already failed, - // and inference still throws when it cannot map the store, so the - // fallback can only turn a crash into a successful open — never - // widen the set of stores that are opened destructively. + // Hosts turn that throw into `fatalError` at launch, so for THAT + // failure retry the way they already open the store themselves: + // the current schema with SwiftData's inferred lightweight + // migration and no plan. The match is deliberately exact, and it + // is made on the store rather than the error (see + // `storeMatchesRegisteredSchema`). Every stage in + // `DashMigrationPlan` is `.lightweight` today, but the day a + // custom stage lands, a failure inside it must surface — a store + // that matches a registered version and still failed to open is + // exactly that case, and falling back would reopen it without the + // stage and stamp the current checksum on it, so the stage could + // never run later. Everything except "existing store, matches no + // registered version" is therefore rethrown untouched, with the + // failed staged attempt reported as such. + guard existedBefore, + Self.storeMatchesRegisteredSchema(at: storeURL) == false + else { + report(succeeded: false, migrationPath: .staged, error: error) + throw error + } SDKLogger.event( "core_store_staged_migration_failed", category: .persistence, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift index 32a901c77aa..0a5b2943cf5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift @@ -99,6 +99,32 @@ enum CoreDiagnosticConstants { static let detailLimit = 25 } +/// Ceilings on what one support export may materialize at once. +/// +/// The exact #4438 audit needs every TXO and every transaction cross-wallet — +/// an output absent from this wallet may be `wrong_wallet`, not `missing_txo`, +/// and only the full table can say which. That pass runs inside the +/// persistence serial queue, so on a heavily mixed wallet it stalls every +/// Rust persister callback and the main thread behind them until it finishes: +/// a watchdog kill, and no export artifact, on exactly the wallet support asked +/// about. A fetch limit is the wrong tool because a truncated table silently +/// misclassifies. Counting first and declining above a ceiling keeps the +/// distinction exact wherever it is computable and refuses honestly where it +/// is not. The figures are a cap on materialized objects, not a tuned number. +struct CoreDiagnosticRowLimits: Sendable { + /// Above this many `PersistentTxo` rows table-wide, only this wallet's rows + /// are fetched and the cross-wallet audit is declined. + let crossWalletTxoRows: Int + /// Above this many `PersistentTransaction` rows table-wide, transaction + /// bodies are not materialized and the exact audit is declined. + let exactAuditTransactionRows: Int + + static let production = CoreDiagnosticRowLimits( + crossWalletTxoRows: 100_000, + exactAuditTransactionRows: 20_000 + ) +} + private extension Data { mutating func appendLittleEndian(_ value: T) { var littleEndian = value.littleEndian @@ -174,14 +200,16 @@ extension PlatformWalletPersistenceHandler { /// resumed across the continuation. func emitCoreWalletDatabaseDiagnostics( walletId: Data, - checkpoint: CoreWalletDiagnosticCheckpoint + checkpoint: CoreWalletDiagnosticCheckpoint, + limits: CoreDiagnosticRowLimits = .production ) async -> CoreWalletDatabaseDiagnosticSnapshot? { await withCheckedContinuation { continuation in serialQueue.async { [self] in let snapshot = autoreleasepool { () -> CoreWalletDatabaseDiagnosticSnapshot? in return emitCoreWalletDatabaseDiagnosticsOnQueue( walletId: walletId, - checkpoint: checkpoint + checkpoint: checkpoint, + limits: limits ) } continuation.resume(returning: snapshot) @@ -190,12 +218,13 @@ extension PlatformWalletPersistenceHandler { } /// Queue-confined implementation behind the async export API. Callers must - /// already own `serialQueue`; it intentionally performs the full exact - /// audit and returns only Sendable value copies. + /// already own `serialQueue`; it performs the full exact audit whenever the + /// tables fit under `limits` and returns only Sendable value copies. @discardableResult func emitCoreWalletDatabaseDiagnosticsOnQueue( walletId: Data, - checkpoint: CoreWalletDiagnosticCheckpoint + checkpoint: CoreWalletDiagnosticCheckpoint, + limits: CoreDiagnosticRowLimits = .production ) -> CoreWalletDatabaseDiagnosticSnapshot? { do { let walletDescriptor = FetchDescriptor( @@ -217,10 +246,23 @@ extension PlatformWalletPersistenceHandler { // Exact #4438 classification needs a complete cross-wallet pass: // an output absent from this wallet may be `wrong_wallet`, not - // `missing_txo`. This first export-only implementation materializes - // that pass. A future bounded version must stream every row rather - // than apply a fetch limit, so it preserves the distinction. - let allTxos = try backgroundContext.fetch(FetchDescriptor()) + // `missing_txo`. Count before materializing (see + // `CoreDiagnosticRowLimits`): under the ceiling the whole table is + // held and the audit is exact; over it only this wallet's rows are + // fetched, and the snapshot says so, because a relationship-only + // row or a cross-wallet duplicate is then invisible to it. A + // streaming pass would lift the ceiling without losing the + // distinction and remains the follow-up. + let txoRowCount = try backgroundContext.fetchCount(FetchDescriptor()) + let crossWalletTxoScan = txoRowCount <= limits.crossWalletTxoRows + let allTxos: [PersistentTxo] + if crossWalletTxoScan { + allTxos = try backgroundContext.fetch(FetchDescriptor()) + } else { + allTxos = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + )) + } let walletTxos = allTxos.filter { $0.walletId == walletId || Self.relationshipWalletId(of: $0) == walletId } @@ -232,12 +274,41 @@ extension PlatformWalletPersistenceHandler { let walletTransactions: [PersistentTransaction]? if checkpoint == .preExport { do { - let fetched = try backgroundContext.fetch( + let transactionRowCount = try backgroundContext.fetchCount( FetchDescriptor() ) - allTransactions = fetched - walletTransactions = fetched.filter { - Self.walletOwnsTransaction(walletId: walletId, transaction: $0) + // Both tables must fit: the audit resolves each decoded + // output against `txoByOutpoint`, so a wallet-only TXO + // scan would turn every foreign row into `missing_txo`. + if crossWalletTxoScan, + transactionRowCount <= limits.exactAuditTransactionRows { + let fetched = try backgroundContext.fetch( + FetchDescriptor() + ) + allTransactions = fetched + walletTransactions = fetched.filter { + Self.walletOwnsTransaction(walletId: walletId, transaction: $0) + } + } else { + allTransactions = nil + walletTransactions = nil + SDKLogger.event( + "core_owned_output_audit_summary", + category: .persistence, + severity: .warning, + fields: [ + "audit_incomplete": .boolean(true), + "checkpoint": .publicText(checkpoint.rawValue), + "reason": .publicText("tables_too_large_for_exact_audit"), + "transaction_row_count": .integer(Int64(transactionRowCount)), + "transaction_row_limit": .integer( + Int64(limits.exactAuditTransactionRows) + ), + "txo_row_count": .integer(Int64(txoRowCount)), + "txo_row_limit": .integer(Int64(limits.crossWalletTxoRows)), + "wallet_reference": .reference(walletId), + ] + ) } } catch { allTransactions = nil @@ -327,6 +398,10 @@ extension PlatformWalletPersistenceHandler { "transaction_scan_available": .boolean(walletTransactions != nil), "txo_count": .integer(Int64(walletTxos.count)), "txo_fingerprint": .reference(txoFingerprint), + "txo_row_count": .integer(Int64(txoRowCount)), + "txo_scan_scope": .publicText( + crossWalletTxoScan ? "cross_wallet" : "wallet_id_only" + ), "unconfirmed_count": .integer(Int64(unconfirmed.count)), "unconfirmed_value_duffs": .unsignedInteger( diagnosticSaturatingSum(unconfirmed.map(\.amount)) @@ -797,12 +872,26 @@ extension PlatformWalletPersistenceHandler { anomalies.append((transaction, vout, output.valueDuffs, outpoint, "missing_txo")) continue } - guard relationshipWalletId(of: row) == walletId, - row.walletId.isEmpty || row.walletId == walletId - else { + // Same admission rule as `walletTxos` and the candidate set: + // the denormalized id OR the relationship may name this + // wallet. Judging by the relationship alone here would report + // this wallet's own row with a broken link as `wrong_wallet`. + let rowRelationshipWallet = relationshipWalletId(of: row) + let denormalizedNamesWallet = row.walletId == walletId + let relationshipNamesWallet = rowRelationshipWallet == walletId + guard denormalizedNamesWallet || relationshipNamesWallet else { anomalies.append((transaction, vout, output.valueDuffs, outpoint, "wrong_wallet")) continue } + guard relationshipNamesWallet else { + // Ours by id, but the relationship says otherwise. Reuse + // the vocabulary `logTxoAnomalies` already emits for the + // same two facts, so the analyst sees one story. + let reason = rowRelationshipWallet == nil + ? "relationship_missing" : "wallet_id_mismatch" + anomalies.append((transaction, vout, output.valueDuffs, outpoint, reason)) + continue + } guard row.account === expectedAccount, row.account?.accountType == 0, row.account?.standardTag == 0 @@ -894,7 +983,7 @@ extension PlatformWalletPersistenceHandler { /// output IS persisted here, whatever else shares the outpoint); otherwise a /// deterministic representative is chosen the way `compareTxos` resolves /// duplicates before comparing. - private static func representativeTxo( + static func representativeTxo( rows: [PersistentTxo]?, walletId: Data ) -> PersistentTxo? { @@ -906,13 +995,16 @@ extension PlatformWalletPersistenceHandler { ) } return ordered.first { - relationshipWalletId(of: $0) == walletId - && ($0.walletId.isEmpty || $0.walletId == walletId) + $0.walletId == walletId || relationshipWalletId(of: $0) == walletId } ?? ordered[0] } /// Total order over rows sharing an outpoint. Uses only persisted bytes, so /// two runs over the same database agree. + /// + /// A single `0` byte separates the two wallet ids. That is unambiguous only + /// because each is exactly 32 bytes or empty — a variable-width id would + /// need length prefixes, as `diagnosticTxoFingerprint` uses. private static func duplicateResolutionKey(_ txo: PersistentTxo) -> Data { var key = Data() key.append(txo.walletId) @@ -1050,6 +1142,17 @@ extension PlatformWalletManager { /// Emit a best-effort, read-only snapshot immediately before a diagnostic /// export. The method intentionally never throws: a failed sub-query is a /// diagnostic fact and is logged as `unavailable`, not reported as zero. + /// + /// Cost, so hosts do not trigger this mid-sync: the SwiftData half runs on + /// the persistence serial queue and holds it for its whole duration, which + /// blocks every Rust persister and SPV callback (they enter through + /// `serialQueue.sync`) and any main-thread persistence access until it + /// returns. Under `CoreDiagnosticRowLimits` that includes materializing + /// the full TXO and transaction tables for the exact #4438 audit; above + /// them the export narrows to this wallet's rows, declines the audit, and + /// says so in `core_owned_output_audit_summary`. A paged variant that + /// lifts the ceilings without losing the classification is tracked as a + /// follow-up. public func emitCoreWalletDiagnostics(for walletId: Data) async { await emitCoreWalletDiagnostics(for: walletId, checkpoint: .preExport) } @@ -1588,7 +1691,7 @@ extension PlatformWalletManager { /// loop: a hand-rolled copy agrees only by coincidence, and any later change /// to the canonical encoder would silently make every lock report as both /// `database_only` and `memory_only`. - private nonisolated static func assetLockOutpointDisplay( + nonisolated static func assetLockOutpointDisplay( txid: Data, vout: UInt32 ) -> String { @@ -1596,6 +1699,9 @@ extension PlatformWalletManager { // `encodeOutPoint` traps on a malformed outpoint. Diagnostics must // survive corrupt input, so fall back to a clearly non-matching marker // that shows up as `memory_only` instead of taking the process down. + // Reachable only through a `txid` that is not 32 bytes: `makeOutpoint` + // appends whatever it is given, and the FFI copies a fixed 32-byte + // array, so this guards the Rust side's word, not this file's. guard raw.count == 36 else { return "invalid_outpoint:\(raw.count)_bytes:\(vout)" } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticAnalyzerTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticAnalyzerTests.swift index 281f1886458..48b4ffe9061 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticAnalyzerTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticAnalyzerTests.swift @@ -278,6 +278,42 @@ final class CoreWalletDiagnosticAnalyzerTests: XCTestCase { ) } + /// `compareAssetLocks` matches the two sides on `outpointDisplay`. The + /// database side is keyed by `PersistentAssetLock.encodeOutPoint`; the + /// memory side by `assetLockOutpointDisplay`. This pins that they are one + /// encoder, so a lock present on both sides is never reported twice. + func testAssetLockDiffKeysAgreeBetweenDatabaseAndMemorySides() { + let txid = Data((0..<32).map { UInt8($0) }) + let databaseKey = PersistentAssetLock.encodeOutPoint( + rawBytes: PersistentTxo.makeOutpoint(txid: txid, vout: 7) + ) + let memoryKey = PlatformWalletManager.assetLockOutpointDisplay(txid: txid, vout: 7) + XCTAssertEqual(databaseKey, memoryKey) + XCTAssertTrue(memoryKey.hasSuffix(":7"), memoryKey) + + let diff = CoreWalletDiagnosticAnalyzer.compareAssetLocks( + database: [assetLock(databaseKey)], + memory: [assetLock(memoryKey)] + ) + XCTAssertTrue(diff.details.isEmpty, "\(diff.details)") + + // A malformed txid must neither trap the exporter nor collide with a + // real key: it surfaces as `memory_only`. + let malformed = PlatformWalletManager.assetLockOutpointDisplay( + txid: Data([1, 2, 3]), + vout: 7 + ) + XCTAssertTrue(malformed.hasPrefix("invalid_outpoint:"), malformed) + let malformedDiff = CoreWalletDiagnosticAnalyzer.compareAssetLocks( + database: [assetLock(databaseKey)], + memory: [assetLock(malformed)] + ) + XCTAssertEqual( + malformedDiff.details.map(\.reason).sorted(), + ["database_only", "memory_only"] + ) + } + func testShieldedStoreSummaryIncludesValuesActivityKeysAndWatermark() { let summary = CoreWalletDiagnosticAnalyzer.summarizeShieldedStore( notes: [ diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticsTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticsTests.swift index 32eb10afcd1..ae2e00f0f20 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticsTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticsTests.swift @@ -171,6 +171,10 @@ final class CoreWalletDiagnosticsTests: XCTestCase { XCTAssertTrue(summary.contains("owned_bip44_output_count=1"), summary) XCTAssertTrue(summary.contains("persisted_valid_count=0"), summary) XCTAssertTrue(summary.contains("total_anomaly_count=1"), summary) + // Output 1 of the fixture is `OP_RETURN`: no address to attribute. + XCTAssertTrue(summary.contains("output_address_undecodable_count=1"), summary) + XCTAssertTrue(summary.contains("unattributed_output_count=0"), summary) + XCTAssertTrue(summary.contains("bip44_address_pool_size=1"), summary) let anomalies = try logLines(in: session, event: "core_owned_output_anomaly") let anomaly = try XCTUnwrap(anomalies.last) @@ -243,6 +247,220 @@ final class CoreWalletDiagnosticsTests: XCTestCase { XCTAssertTrue(try logLines(in: session, event: "core_owned_output_anomaly").isEmpty) } + /// Above the row ceilings the export must refuse the exact audit outright + /// and say so — never truncate the table and misclassify — while every + /// lightweight snapshot still lands. The fixture has two transactions and + /// one TXO, so a ceiling of one transaction is over the line. + func testExportDeclinesExactAuditAboveRowLimitsButKeepsSnapshots() async throws { + let fixture = try makeMissingOwnedOutputFixture() + let session = try temporaryDirectory() + XCTAssertTrue(SDKLogger.installFileSink(at: session, includeDebug: false)) + + let databaseSnapshot = await fixture.handler.emitCoreWalletDatabaseDiagnostics( + walletId: walletId, + checkpoint: .preExport, + limits: CoreDiagnosticRowLimits( + crossWalletTxoRows: 100, + exactAuditTransactionRows: 1 + ) + ) + XCTAssertNotNil(databaseSnapshot) + + let summaries = try logLines(in: session, event: "core_owned_output_audit_summary") + let summary = try XCTUnwrap(summaries.last) + XCTAssertTrue(summary.contains("audit_incomplete=true"), summary) + XCTAssertTrue(summary.contains(#"reason="tables_too_large_for_exact_audit""#), summary) + XCTAssertTrue(summary.contains("transaction_row_count=2"), summary) + XCTAssertTrue(summary.contains("transaction_row_limit=1"), summary) + XCTAssertTrue(summary.contains("txo_row_count=1"), summary) + // Declining is not the same as finding nothing: no per-output verdict + // may be emitted for an audit that never ran. + XCTAssertFalse(summary.contains("coinjoin_to_bip44_missing_count"), summary) + XCTAssertTrue(try logLines(in: session, event: "core_owned_output_anomaly").isEmpty) + + let walletSnapshot = try XCTUnwrap( + try logLines(in: session, event: "core_db_wallet_snapshot").last + ) + XCTAssertTrue(walletSnapshot.contains(#"txo_scan_scope="cross_wallet""#), walletSnapshot) + XCTAssertTrue(walletSnapshot.contains("transaction_scan_available=false"), walletSnapshot) + XCTAssertTrue(walletSnapshot.contains("txo_count=1"), walletSnapshot) + XCTAssertFalse(try logLines(in: session, event: "core_db_account_snapshot").isEmpty) + XCTAssertFalse(try logLines(in: session, event: "core_db_anomaly_summary").isEmpty) + XCTAssertFalse(try logLines(in: session, event: "asset_lock_db_snapshot").isEmpty) + } + + /// Over the TXO ceiling the scan narrows to this wallet's denormalized id + /// and the snapshot records that scope, so an analyst knows relationship- + /// only rows and cross-wallet duplicates were outside its view. + func testExportNarrowsTxoScanToWalletAboveTxoRowLimit() async throws { + let fixture = try makeMissingOwnedOutputFixture() + let session = try temporaryDirectory() + XCTAssertTrue(SDKLogger.installFileSink(at: session, includeDebug: false)) + + let databaseSnapshot = await fixture.handler.emitCoreWalletDatabaseDiagnostics( + walletId: walletId, + checkpoint: .preExport, + limits: CoreDiagnosticRowLimits( + crossWalletTxoRows: 0, + exactAuditTransactionRows: 100 + ) + ) + XCTAssertNotNil(databaseSnapshot) + + let walletSnapshot = try XCTUnwrap( + try logLines(in: session, event: "core_db_wallet_snapshot").last + ) + XCTAssertTrue(walletSnapshot.contains(#"txo_scan_scope="wallet_id_only""#), walletSnapshot) + XCTAssertTrue(walletSnapshot.contains("txo_count=1"), walletSnapshot) + // A narrowed TXO scan alone is enough to decline the audit, even + // though the transaction table is under its own ceiling. + let summary = try XCTUnwrap( + try logLines(in: session, event: "core_owned_output_audit_summary").last + ) + XCTAssertTrue(summary.contains(#"reason="tables_too_large_for_exact_audit""#), summary) + XCTAssertTrue(summary.contains("txo_row_limit=0"), summary) + } + + /// Without the change address's `PersistentCoreAddress` row the audit can + /// attribute nothing, and must say so through the counter rather than + /// report a clean wallet — this is the "false all-clear" from review. + func testMissingAddressRowIsCountedAsUnattributedNotCleared() async throws { + let fixture = try makeMissingOwnedOutputFixture() + fixture.context.delete(fixture.bip44Address) + try fixture.context.save() + + let session = try temporaryDirectory() + XCTAssertTrue(SDKLogger.installFileSink(at: session, includeDebug: false)) + let databaseSnapshot = await fixture.handler.emitCoreWalletDatabaseDiagnostics( + walletId: walletId, + checkpoint: .preExport + ) + XCTAssertNotNil(databaseSnapshot) + + let summary = try XCTUnwrap( + try logLines(in: session, event: "core_owned_output_audit_summary").last + ) + XCTAssertTrue(summary.contains("candidate_transaction_count=1"), summary) + XCTAssertTrue(summary.contains("bip44_address_pool_size=0"), summary) + XCTAssertTrue(summary.contains("unattributed_output_count=1"), summary) + XCTAssertTrue(summary.contains("output_address_undecodable_count=1"), summary) + XCTAssertTrue(summary.contains("owned_bip44_output_count=0"), summary) + // Zero here means "of what could be attributed" — and the counters + // above show that was nothing. + XCTAssertTrue(summary.contains("coinjoin_to_bip44_missing_count=0"), summary) + XCTAssertTrue(summary.contains("total_anomaly_count=0"), summary) + } + + /// A change row that names this wallet by its denormalized id but has no + /// account relationship is this wallet's row with a broken link, not + /// another wallet's row — the same rule that admits it must judge it. + func testOwnedRowWithBrokenRelationshipIsRelationshipMissingNotWrongWallet() async throws { + let fixture = try makeMissingOwnedOutputFixture() + let output = fixture.decoded.outputs[0] + let change = PersistentTxo( + transaction: fixture.spendingTransaction, + vout: 0, + amount: output.valueDuffs, + address: try XCTUnwrap(output.address), + scriptPubKey: output.scriptPubkey, + height: fixture.spendingTransaction.blockHeight + ) + change.walletId = walletId + change.isConfirmed = true + fixture.context.insert(change) + try fixture.context.save() + + let session = try temporaryDirectory() + XCTAssertTrue(SDKLogger.installFileSink(at: session, includeDebug: false)) + let databaseSnapshot = await fixture.handler.emitCoreWalletDatabaseDiagnostics( + walletId: walletId, + checkpoint: .preExport + ) + XCTAssertNotNil(databaseSnapshot) + + let summary = try XCTUnwrap( + try logLines(in: session, event: "core_owned_output_audit_summary").last + ) + XCTAssertTrue(summary.contains("coinjoin_to_bip44_missing_count=0"), summary) + XCTAssertTrue(summary.contains("total_anomaly_count=1"), summary) + let anomaly = try XCTUnwrap( + try logLines(in: session, event: "core_owned_output_anomaly").last + ) + XCTAssertTrue(anomaly.contains(#"reason="relationship_missing""#), anomaly) + XCTAssertFalse(anomaly.contains("wrong_wallet"), anomaly) + } + + /// `outpoint` is `@Attribute(.unique)`, so a duplicated outpoint only ever + /// exists in a corrupt store and cannot be saved through a context. The + /// resolver is therefore exercised on transient rows. + func testRepresentativeTxoPrefersThisWalletsRowRegardlessOfFetchOrder() throws { + let ours = PersistentWallet(walletId: walletId, network: .testnet) + let theirs = PersistentWallet(walletId: Data(repeating: 0xB2, count: 32), network: .testnet) + let ourAccount = PersistentAccount( + wallet: ours, accountType: 0, accountIndex: 0, accountTypeName: "Standard" + ) + let theirAccount = PersistentAccount( + wallet: theirs, accountType: 0, accountIndex: 0, accountTypeName: "Standard" + ) + let transaction = PersistentTransaction( + txid: Data(repeating: 0x44, count: 32), + transactionData: Data(), + context: 2, + blockHeight: 1, + netAmount: 0 + ) + func row(amount: UInt64, account: PersistentAccount?, walletId: Data) -> PersistentTxo { + let txo = PersistentTxo( + transaction: transaction, + vout: 0, + amount: amount, + address: "duplicate-outpoint", + scriptPubKey: Data([0x51]), + height: 1 + ) + txo.account = account + txo.walletId = walletId + return txo + } + let ourRow = row(amount: 1, account: ourAccount, walletId: walletId) + let theirRow = row(amount: 2, account: theirAccount, walletId: theirs.walletId) + let theirOtherRow = row(amount: 3, account: theirAccount, walletId: theirs.walletId) + + XCTAssertTrue( + PlatformWalletPersistenceHandler.representativeTxo( + rows: [theirRow, ourRow], walletId: walletId + ) === ourRow + ) + XCTAssertTrue( + PlatformWalletPersistenceHandler.representativeTxo( + rows: [ourRow, theirRow], walletId: walletId + ) === ourRow + ) + // Ours by denormalized id alone still wins: the audit judges by the + // same rule that admits, and reports the broken link separately. + let ourBrokenRow = row(amount: 4, account: nil, walletId: walletId) + XCTAssertTrue( + PlatformWalletPersistenceHandler.representativeTxo( + rows: [theirRow, ourBrokenRow], walletId: walletId + ) === ourBrokenRow + ) + // No owned row: still the same answer whichever order the fetch gave. + let forward = PlatformWalletPersistenceHandler.representativeTxo( + rows: [theirRow, theirOtherRow], walletId: walletId + ) + let reversed = PlatformWalletPersistenceHandler.representativeTxo( + rows: [theirOtherRow, theirRow], walletId: walletId + ) + XCTAssertNotNil(forward) + XCTAssertTrue(forward === reversed) + XCTAssertNil( + PlatformWalletPersistenceHandler.representativeTxo(rows: nil, walletId: walletId) + ) + XCTAssertNil( + PlatformWalletPersistenceHandler.representativeTxo(rows: [], walletId: walletId) + ) + } + func testRestoreOnlyLogsLightweightBufferSnapshotAndNoDeepStartupEvents() throws { let fixture = try makeMissingOwnedOutputFixture() fixture.bip44Account.accountExtendedPubKeyBytes = Data(repeating: 0x02, count: 78) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift index 672248b788f..7724712d031 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift @@ -13,12 +13,15 @@ import XCTest /// the drifted `PersistentDocumentType` / `PersistentIndex` shapes leave a /// dev.1 store matching no registered version — Cocoa error 134504. Hosts turn /// that throw into a launch crash, which is why `DashModelContainer.open` falls -/// back to inferred lightweight migration. This test drives that production -/// entry point rather than rebuilding a look-alike container, so the fallback -/// cannot regress unnoticed. +/// back to inferred lightweight migration for exactly that error. These tests +/// drive that production entry point rather than rebuilding a look-alike +/// container, so the fallback — and its limits — cannot regress unnoticed. @MainActor final class Dev1StoreUpgradeTests: XCTestCase { - func testDev1StoreOpensThroughProductionFactoryAndPreservesCoreRows() throws { + private var directory: URL! + private var fixtureSQLite: Data! + + override func setUp() async throws { let resourceURL = try XCTUnwrap( Bundle.module.url( forResource: "DashModel-v4.2.0-dev.1.sqlite", @@ -29,47 +32,54 @@ final class Dev1StoreUpgradeTests: XCTestCase { let compressed = try Data(contentsOf: resourceURL) // This resource is produced with Foundation's `.zlib` compressor. // A Python zlib-wrapped stream is not accepted by NSData on iOS. - let sqlite = try (compressed as NSData).decompressed(using: .zlib) as Data - XCTAssertEqual(sqlite.count, 647_168) + fixtureSQLite = try (compressed as NSData).decompressed(using: .zlib) as Data + XCTAssertEqual(fixtureSQLite.count, 647_168) - let directory = FileManager.default.temporaryDirectory + directory = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory( at: directory, withIntermediateDirectories: true ) - addTeardownBlock { - try? FileManager.default.removeItem(at: directory) - } + // Every test installs its own sink before touching a store, so the + // log it reads holds only its own lines and nothing buffered by an + // earlier test can replay into it. + XCTAssertTrue(SDKLogger.installFileSink(at: directory, includeDebug: false)) + } - func configuration(named name: String) throws -> ModelConfiguration { - let storeURL = directory.appendingPathComponent(name) - try sqlite.write(to: storeURL, options: .atomic) - return ModelConfiguration( - schema: DashModelContainer.schema, - url: storeURL, - allowsSave: true, - cloudKitDatabase: .none - ) - } + override func tearDown() async throws { + try? FileManager.default.removeItem(at: directory) + } - // The staged plan on its own is what crashes hosts today. Assert it on - // its own copy of the fixture — a failed open must not be what the - // production path below is then handed — so this test keeps naming the - // cause once the remaining models are frozen and it starts succeeding. - XCTAssertThrowsError( - try ModelContainer( - for: DashModelContainer.schema, - migrationPlan: DashMigrationPlan.self, - configurations: [try configuration(named: "StagedOnly.sqlite")] - ) + /// A fresh copy of the dev.1 fixture under `name`, as a store configuration. + private func dev1Configuration(named name: String) throws -> ModelConfiguration { + let storeURL = directory.appendingPathComponent(name) + try fixtureSQLite.write(to: storeURL, options: .atomic) + return configuration(at: storeURL) + } + + private func configuration(at storeURL: URL) -> ModelConfiguration { + ModelConfiguration( + schema: DashModelContainer.schema, + url: storeURL, + allowsSave: true, + cloudKitDatabase: .none ) + } - let container = try DashModelContainer.open( - try configuration(named: "DashModel.sqlite") + private func logLines(event: String) throws -> [String] { + SDKLogger.flush() + let log = try String( + contentsOf: directory.appendingPathComponent("swift/run.log"), + encoding: .utf8 ) - let context = ModelContext(container) + return log.split(separator: "\n").map(String.init).filter { + $0.contains("event=\(event) ") + } + } + private func assertDev1Rows(in container: ModelContainer) throws { + let context = ModelContext(container) let wallets = try context.fetch(FetchDescriptor()) let accounts = try context.fetch(FetchDescriptor()) @@ -86,4 +96,86 @@ final class Dev1StoreUpgradeTests: XCTestCase { ) XCTAssertEqual(accounts[0].wallet.walletId, wallets[0].walletId) } + + func testDev1StoreOpensThroughProductionFactoryAndPreservesCoreRows() throws { + // The staged plan on its own is what crashes hosts today. Assert it on + // its own copy of the fixture — a failed open must not be what the + // production path below is then handed — and assert the precondition + // the fallback keys on: this store matches no registered version. The + // pair keeps naming the cause until the remaining models are frozen, + // at which point the match flips to `true` and the staged attempt + // starts succeeding. + let stagedOnly = try dev1Configuration(named: "StagedOnly.sqlite") + XCTAssertEqual(DashModelContainer.storeMatchesRegisteredSchema(at: stagedOnly.url), false) + XCTAssertThrowsError( + try ModelContainer( + for: DashModelContainer.schema, + migrationPlan: DashMigrationPlan.self, + configurations: [stagedOnly] + ) + ) + + let container = try DashModelContainer.open( + try dev1Configuration(named: "DashModel.sqlite") + ) + try assertDev1Rows(in: container) + + let staged = try logLines(event: "core_store_staged_migration_failed") + XCTAssertEqual(staged.count, 1, staged.joined(separator: "\n")) + let result = try XCTUnwrap(try logLines(event: "core_store_open_result").last) + XCTAssertTrue(result.contains(#"migration_path="inferred_fallback""#), result) + XCTAssertTrue(result.contains(#"result="success""#), result) + } + + /// The fallback's "self-heal" claim: once inferred migration has opened a + /// dev.1 store, it carries the current schema's checksum, so the very next + /// open must succeed through the staged plan with no fallback at all. + func testFallbackMigratedStoreReopensThroughStagedPlan() throws { + let storeURL = directory.appendingPathComponent("DashModel.sqlite") + try fixtureSQLite.write(to: storeURL, options: .atomic) + + XCTAssertEqual(DashModelContainer.storeMatchesRegisteredSchema(at: storeURL), false) + try autoreleasepool { + let first = try DashModelContainer.open(configuration(at: storeURL)) + try assertDev1Rows(in: first) + } + XCTAssertEqual(try logLines(event: "core_store_staged_migration_failed").count, 1) + // Inferred migration rewrote the store under the current schema, so + // it now matches a registered version and the fallback is never + // needed again. + XCTAssertEqual(DashModelContainer.storeMatchesRegisteredSchema(at: storeURL), true) + + let second = try DashModelContainer.open(configuration(at: storeURL)) + try assertDev1Rows(in: second) + + // Still exactly one staged failure — the reopen did not need the + // fallback — and the latest result names the staged path. + XCTAssertEqual(try logLines(event: "core_store_staged_migration_failed").count, 1) + let results = try logLines(event: "core_store_open_result") + XCTAssertEqual(results.count, 2, results.joined(separator: "\n")) + let reopen = try XCTUnwrap(results.last) + XCTAssertTrue(reopen.contains(#"migration_path="staged""#), reopen) + XCTAssertTrue(reopen.contains(#"result="success""#), reopen) + XCTAssertTrue(reopen.contains("store_existed_before_open=true"), reopen) + } + + /// Any failure other than "unknown model version" must surface untouched: + /// once a custom `MigrationStage` exists, a failure inside it reopened + /// without the plan would stamp the current checksum on a store that never + /// ran that stage, so it could never run later. + func testNonMigrationOpenFailureIsRethrownWithoutFallback() throws { + let storeURL = directory.appendingPathComponent("DashModel.sqlite") + try Data(repeating: 0x5A, count: 4096).write(to: storeURL, options: .atomic) + + // Unreadable metadata is not a version question. + XCTAssertNil(DashModelContainer.storeMatchesRegisteredSchema(at: storeURL)) + XCTAssertThrowsError(try DashModelContainer.open(configuration(at: storeURL))) + + XCTAssertTrue(try logLines(event: "core_store_staged_migration_failed").isEmpty) + let result = try XCTUnwrap(try logLines(event: "core_store_open_result").last) + XCTAssertTrue(result.contains(#"migration_path="staged""#), result) + XCTAssertTrue(result.contains(#"result="failure""#), result) + // The failed store's path is redacted from the error message. + XCTAssertFalse(result.contains(storeURL.path), result) + } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SDKLoggerPreInstallBufferTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SDKLoggerPreInstallBufferTests.swift new file mode 100644 index 00000000000..7076adef985 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SDKLoggerPreInstallBufferTests.swift @@ -0,0 +1,97 @@ +import Foundation +import XCTest + +@testable import SwiftDashSDK + +/// The file sink is installed by `LoggingPreferences.configure()`, well after +/// the host has already opened its store in `init()`. Everything emitted in +/// between must reach `swift/run.log` in order once the sink exists — and a +/// host that never installs one must not grow the backlog without bound. +/// +/// Tested on a fresh `SDKLoggerState` rather than through `SDKLogger`: the +/// process-wide singleton has no way back to "no sink installed" once any +/// test has installed one. +final class SDKLoggerPreInstallBufferTests: XCTestCase { + private var session: URL! + + override func setUpWithError() throws { + session = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: session, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: session) + } + + private func writtenLines(_ state: SDKLoggerState) throws -> [String] { + state.flush() + let log = try String( + contentsOf: session.appendingPathComponent("swift/run.log"), + encoding: .utf8 + ) + return log.split(separator: "\n").map(String.init) + } + + func testLinesRecordedBeforeInstallReplayInOrderAheadOfLaterLines() throws { + let state = SDKLoggerState() + state.record(severity: .info, line: "first") + state.record(severity: .warning, line: "second") + + let outcome = state.installSink(at: session, includeDebug: false) + XCTAssertTrue(outcome.installed) + XCTAssertEqual(outcome.droppedPendingLineCount, 0) + + state.record(severity: .error, line: "third") + XCTAssertEqual(try writtenLines(state), ["first", "second", "third"]) + } + + func testReplayHonoursTheSinkDebugSettingItDidNotKnowYet() throws { + // Before install nothing knows whether debug lines are wanted, so + // they are buffered regardless and filtered at replay. + let hidden = SDKLoggerState() + hidden.record(severity: .debug, line: "debug") + hidden.record(severity: .info, line: "info") + _ = hidden.installSink(at: session, includeDebug: false) + XCTAssertEqual(try writtenLines(hidden), ["info"]) + + try FileManager.default.removeItem(at: session.appendingPathComponent("swift")) + let shown = SDKLoggerState() + shown.record(severity: .debug, line: "debug") + shown.record(severity: .info, line: "info") + _ = shown.installSink(at: session, includeDebug: true) + XCTAssertEqual(try writtenLines(shown), ["debug", "info"]) + } + + func testBufferDropsTheOldestLineAboveTheLimitAndReportsIt() throws { + let state = SDKLoggerState() + let limit = SDKLoggerState.pendingLineLimit + // limit + 1 lines: exactly one over. + for index in 0...limit { + state.record(severity: .info, line: "line-\(index)") + } + + let outcome = state.installSink(at: session, includeDebug: false) + XCTAssertTrue(outcome.installed) + // This count is what `SDKLogger.installFileSink` turns into the + // `log_pre_install_buffer_overflow` event after the replay. + XCTAssertEqual(outcome.droppedPendingLineCount, 1) + + let lines = try writtenLines(state) + XCTAssertEqual(lines.count, limit) + XCTAssertEqual(lines.first, "line-1", "the oldest line is the one dropped") + XCTAssertEqual(lines.last, "line-\(limit)") + } + + func testBufferIsClearedByInstallSoASecondInstallReplaysNothing() throws { + let state = SDKLoggerState() + state.record(severity: .info, line: "once") + _ = state.installSink(at: session, includeDebug: false) + XCTAssertEqual(try writtenLines(state), ["once"]) + + try FileManager.default.removeItem(at: session.appendingPathComponent("swift")) + let outcome = state.installSink(at: session, includeDebug: false) + XCTAssertEqual(outcome.droppedPendingLineCount, 0) + XCTAssertEqual(try writtenLines(state), []) + } +} From 9d75b9326ad5e71c355cd939ff7f71932934a9e2 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 7 Sep 2026 10:44:55 +0200 Subject: [PATCH 06/17] fix(swift-sdk): lower the exact-audit transaction ceiling to 10k MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `walletOwnsTransaction` faults four relationships per transaction cross-wallet, each a query under the coordinator lock, so the transaction count — not decoding — decides how long the export holds the persistence queue. 20k was a guess; 10k keeps the exact #4438 audit on ordinary wallets while bounding the worst case to a few seconds rather than tens. The paged variant (#4607) is what lifts this properly. Co-Authored-By: Claude Fable 5.1 --- .../PlatformWalletManagerCoreDiagnostics.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift index 0a5b2943cf5..02cd97a3957 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift @@ -119,9 +119,13 @@ struct CoreDiagnosticRowLimits: Sendable { /// bodies are not materialized and the exact audit is declined. let exactAuditTransactionRows: Int + /// The transaction ceiling is the one that matters: `walletOwnsTransaction` + /// faults four relationships per transaction cross-wallet, each a query + /// under the coordinator lock, so it — not decoding — dominates the time + /// the persistence queue is held. static let production = CoreDiagnosticRowLimits( crossWalletTxoRows: 100_000, - exactAuditTransactionRows: 20_000 + exactAuditTransactionRows: 10_000 ) } From 8fc2003fdca7f6a0f0189e5514f2858f5ce56242 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 7 Sep 2026 15:22:01 +0200 Subject: [PATCH 07/17] fix(swift-sdk): refuse the migration fallback for newer stores, and round-3 diagnostics review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store opening: - `classifyStore(at:)` replaces the `Bool?` check with a verdict, and the inferred-migration fallback runs only for `driftedRegisteredVersion`. A store from a newer build — a `VersionedSchema` identifier this plan never registered (SwiftData writes them into `NSStoreModelVersionIdentifiers`), or an entity the current schema lacks — is `newerThanRegistered` and is rethrown: inferred migration would open it and drop what the newer build wrote without a word, which is worse than the crash it replaced. Unreadable stores and stores that match a registered version but failed anyway are rethrown too. `store_verdict` is logged on both events. The residual (a newer build that only added an attribute and kept the identifier looks like drift) is documented on `classifyStore`; freezing the remaining shapes is what closes it. - `open(_:)` refuses a configuration whose `Schema` differs from the SDK's (`DashModelContainerError.schemaMismatch`) instead of silently building the container for the SDK schema anyway; the configuration contributes URL and options only, and the doc says so. - `fileSize(at:)` reads through a fresh URL so the after-open size is not `NSURL`'s cached before-open value. Audit correctness: - Stub transactions (empty `transactionData`, a real production state) are counted into `transaction_bytes_missing_count` and make the audit incomplete instead of being skipped before decoding. - `accountOrder` is the one comparator for every per-account pass, so the BIP44 account that wins a duplicated address is the same on every export. - `duplicateResolutionKey` includes the account identity, so rows differing only by account no longer tie in an unstable sort. - Restore rows no wallet can claim are counted and reported once in `core_restore_unroutable_rows`. - A rescan request rejected for a bad wallet id now logs `core_rescan_armed result="invalid_wallet_id"` like every other exit. Lifecycle: - `shutdown()` raises `coreDiagnosticsCancellation` before draining the diagnostics admission; the off-main pass checks it before every FFI read and returns, so the drain waits for at most the read already in flight. Cost on the held queue: - One grouped pass per wallet replaces the per-account identity filter and five filters per account. - The two transaction counts come from `wallet.accounts.involvedTransactions` instead of `walletOwnsTransaction` over every row (four faults each); fields renamed `involved_transaction_count` / `involved_type_8_transaction_count` to say which relation they follow. - Anomaly counts per reason are computed once from the existing grouping. Cleanup: - The always-`.preExport` `checkpoint` parameter is gone from the database diagnostics entry points, with its dead arms; the static helpers keep it because they also serve the restore-path event. - `readAccountBalances(handle:walletId:)` is the one FFI reader; `accountBalances(for:)` wraps it and the diagnostics' copy is deleted. - `SDKLogger.resetForTesting()` lets every suite that asserts over a whole `run.log` start from an empty backlog regardless of test order. Co-Authored-By: Claude Fable 5.1 --- .../Core/Services/SDKLogger.swift | 18 + .../Persistence/DashModelContainer.swift | 149 +++++-- .../CoreWalletDiagnosticAnalyzers.swift | 12 +- .../PlatformWalletManager.swift | 69 ++-- ...PlatformWalletManagerCoreDiagnostics.swift | 383 ++++++++++-------- .../PlatformWalletManagerSPV.swift | 13 + .../PlatformWalletPersistenceHandler.swift | 22 +- .../AssetLockSpendVisibilityTests.swift | 2 + .../CoreWalletDiagnosticsTests.swift | 28 +- .../Dev1StoreUpgradeTests.swift | 66 ++- .../PlatformWalletShutdownTests.swift | 12 + 11 files changed, 528 insertions(+), 246 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift index 1a83ad3ad25..bea5f195f02 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift @@ -288,6 +288,19 @@ final class SDKLoggerState: @unchecked Sendable { func flush() { lock.withLock { sink }?.flush() } + + /// Test seam: drop the sink and everything buffered for it. `record` + /// buffers process-wide while no sink exists and `installSink` replays + /// the whole backlog into whichever session installs first, so a suite + /// asserting over a complete `run.log` must start from nothing. + func reset() { + lock.withLock { + sink = nil + includeDebug = false + pendingLines = [] + droppedPendingLineCount = 0 + } + } } // MARK: - Logging Preferences @@ -562,6 +575,11 @@ public enum SDKLogger { state.updateDebugSetting(includeDebug) } + /// Tests only. See `SDKLoggerState.reset()`. + static func resetForTesting() { + state.reset() + } + 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 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 13402f0fb59..153e5f439e4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -2,6 +2,14 @@ import CoreData import Foundation import SwiftData +/// Why `DashModelContainer.open` refused to open a store. +public enum DashModelContainerError: Error, Equatable { + /// The configuration was built from a `Schema` whose entity set differs + /// from the SDK's. `unexpected` names entities the SDK schema lacks; + /// `missing` names SDK entities the configuration lacks. + case schemaMismatch(unexpected: [String], missing: [String]) +} + /// Factory for creating SwiftData model containers for Dash Platform persistence public enum DashModelContainer { private struct StoreFileSizes { @@ -25,30 +33,83 @@ public enum DashModelContainer { case inferredFallback = "inferred_fallback" } - /// Whether the store at `storeURL` was written by a schema that + /// What the store at `storeURL` is, relative to the schemas /// `DashMigrationPlan` registers. /// - /// This is the question staged migration asks and answers with Cocoa - /// 134504 ("Cannot use staged migration with an unknown model version") - /// when the answer is no. It has to be asked here directly, because the + /// This is the question staged migration answers with Cocoa 134504 when + /// it cannot place a store. It has to be asked here directly, because the /// error SwiftData surfaces for it is `SwiftDataError.loadIssueModelContainer` /// with no explanation and no underlying `NSError` — the same value a - /// corrupt file produces — so nothing in the thrown error distinguishes the - /// one failure the fallback may answer from every failure it must not. + /// corrupt file produces — so nothing in the thrown error distinguishes + /// the one failure the fallback may answer from every failure it must not. + enum StoreSchemaVerdict: Equatable { + /// The metadata could not be read: not a version question. + case unreadable + /// A registered schema is compatible with it. The staged plan can + /// open it, so a failure to do so is something else entirely. + case matchesRegisteredVersion + /// Written by a registered version whose live models have since + /// drifted (every v4.2.0-dev.1 store, until the remaining V1/V2 + /// shapes are frozen). Inferred migration may open it. + case driftedRegisteredVersion + /// Written by a build this SDK does not know — a version identifier + /// it never registered, or an entity its schema lacks. Inferred + /// migration would open it and silently drop what the newer build + /// wrote, so it must not run; the pre-fallback crash was the safe + /// outcome here. + case newerThanRegistered(reason: String) + + var logLabel: String { + switch self { + case .unreadable: return "unreadable" + case .matchesRegisteredVersion: return "matches_registered_version" + case .driftedRegisteredVersion: return "drifted_registered_version" + case .newerThanRegistered(let reason): return "newer_than_registered:\(reason)" + } + } + } + + /// Classifies the store from its metadata alone; never opens it. /// - /// Returns `nil` when the metadata cannot be read at all: that is not a - /// version question, and the caller treats it exactly like a match. - static func storeMatchesRegisteredSchema(at storeURL: URL) -> Bool? { + /// Known blind spot, recorded rather than hidden: a newer build that added + /// only an *attribute* to an existing entity and kept the version + /// identifier is indistinguishable here from drift, because both leave + /// the same entity names with different hashes. Freezing the remaining + /// shapes in `DashSchemaFrozenModels.swift` is what closes that, by + /// making every registered version's hashes stable. + static func classifyStore(at storeURL: URL) -> StoreSchemaVerdict { guard let metadata = try? NSPersistentStoreCoordinator.metadataForPersistentStore( ofType: NSSQLiteStoreType, at: storeURL, options: nil - ) else { return nil } - return DashMigrationPlan.schemas.contains { schema in + ) else { return .unreadable } + + let matches = DashMigrationPlan.schemas.contains { schema in guard let model = NSManagedObjectModel.makeManagedObjectModel(for: schema.models) else { return false } return model.isConfiguration(withName: nil, compatibleWithStoreMetadata: metadata) } + if matches { return .matchesRegisteredVersion } + + // SwiftData writes each `VersionedSchema.versionIdentifier` into the + // store; one this plan never registered was written by a newer build. + let registered = Set(DashMigrationPlan.schemas.map { $0.versionIdentifier.description }) + let written = (metadata[NSStoreModelVersionIdentifiersKey] as? [String]) ?? [] + if let unknown = written.first(where: { !registered.contains($0) }) { + return .newerThanRegistered(reason: "unregistered_version_identifier=\(unknown)") + } + + // An entity the current schema does not have can only have been + // written by a newer build; inferred migration would drop its table. + let current = Set(schema.entities.map(\.name)) + let stored = Set(((metadata[NSStoreModelVersionHashesKey] as? [String: Any]) ?? [:]).keys) + let unknownEntities = stored.subtracting(current).sorted() + if !unknownEntities.isEmpty { + return .newerThanRegistered( + reason: "unknown_entities=\(unknownEntities.joined(separator: "|"))" + ) + } + return .driftedRegisteredVersion } /// Builds the common payload for both sides of the container open. The @@ -107,7 +168,12 @@ public enum DashModelContainer { /// Read only sizes and never include any component of the device path. private static func storeFileSizes(at storeURL: URL) -> StoreFileSizes { func fileSize(at url: URL) -> UInt64 { - guard let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize, + // A fresh URL each time: `resourceValues` bridges to `NSURL`, + // which caches a value per instance, and the same `storeURL` is + // read before and after the open. A cached size would report a + // migration that grew the store as no growth at all. + let fresh = URL(fileURLWithPath: url.path) + guard let size = try? fresh.resourceValues(forKeys: [.fileSizeKey]).fileSize, size >= 0 else { return 0 } return UInt64(size) @@ -224,7 +290,23 @@ public enum DashModelContainer { /// instead of a bare `ModelContainer(for:configurations:)` that reports /// nothing. It is also what lets `Dev1StoreUpgradeTests` drive exactly the /// path that ships against a fixture store. + /// + /// The configuration contributes the store URL and options only. The + /// container is always built for the SDK's own `schema`, because that is + /// what `DashMigrationPlan` migrates toward; a configuration built from a + /// different `Schema` is refused with ``DashModelContainerError`` rather + /// than silently opened under the wrong one. public static func open(_ modelConfiguration: ModelConfiguration) throws -> ModelContainer { + if let provided = modelConfiguration.schema { + let providedNames = Set(provided.entities.map(\.name)) + let sdkNames = Set(schema.entities.map(\.name)) + guard providedNames == sdkNames else { + throw DashModelContainerError.schemaMismatch( + unexpected: providedNames.subtracting(sdkNames).sorted(), + missing: sdkNames.subtracting(providedNames).sorted() + ) + } + } // Always wire the migration plan so stores created by an older SDK // advance through the registered versioned schemas. Record only // metadata about the store — never its device path. @@ -236,20 +318,25 @@ public enum DashModelContainer { func report( succeeded: Bool, migrationPath: StoreMigrationPath, - error: Error? = nil + error: Error? = nil, + storeVerdict: StoreSchemaVerdict? = nil ) { + var fields = storeOpenFields( + succeeded: succeeded, + existedBefore: existedBefore, + migrationPath: migrationPath, + startedAt: started, + sizeBefore: sizeBefore, + sizeAfter: storeFileSizes(at: storeURL) + ) + if let storeVerdict { + fields["store_verdict"] = .publicText(storeVerdict.logLabel) + } SDKLogger.event( "core_store_open_result", category: .persistence, severity: succeeded ? .info : .error, - fields: storeOpenFields( - succeeded: succeeded, - existedBefore: existedBefore, - migrationPath: migrationPath, - startedAt: started, - sizeBefore: sizeBefore, - sizeAfter: storeFileSizes(at: storeURL) - ), + fields: fields, error: error, redacting: [storeURL.path] ) @@ -278,19 +365,22 @@ public enum DashModelContainer { // the current schema with SwiftData's inferred lightweight // migration and no plan. The match is deliberately exact, and it // is made on the store rather than the error (see - // `storeMatchesRegisteredSchema`). Every stage in + // `classifyStore`). Every stage in // `DashMigrationPlan` is `.lightweight` today, but the day a // custom stage lands, a failure inside it must surface — a store // that matches a registered version and still failed to open is // exactly that case, and falling back would reopen it without the // stage and stamp the current checksum on it, so the stage could - // never run later. Everything except "existing store, matches no - // registered version" is therefore rethrown untouched, with the - // failed staged attempt reported as such. - guard existedBefore, - Self.storeMatchesRegisteredSchema(at: storeURL) == false - else { - report(succeeded: false, migrationPath: .staged, error: error) + // never run later. And a store written by a NEWER build — a + // downgrade — would be opened by inference and silently trimmed + // to this schema, which is worse than the crash it replaces. + // Everything except "existing store, drifted registered version" + // is therefore rethrown untouched, with the verdict in the log. + let verdict: StoreSchemaVerdict = existedBefore + ? Self.classifyStore(at: storeURL) + : .unreadable + guard case .driftedRegisteredVersion = verdict else { + report(succeeded: false, migrationPath: .staged, error: error, storeVerdict: verdict) throw error } SDKLogger.event( @@ -299,6 +389,7 @@ public enum DashModelContainer { severity: .warning, fields: [ "store_existed_before_open": .boolean(existedBefore), + "store_verdict": .publicText(verdict.logLabel), ], error: error, redacting: [storeURL.path] diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWalletDiagnosticAnalyzers.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWalletDiagnosticAnalyzers.swift index b2955e42ef8..4be235ecaee 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWalletDiagnosticAnalyzers.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWalletDiagnosticAnalyzers.swift @@ -378,9 +378,12 @@ enum CoreWalletDiagnosticAnalyzer { let details: [DatabaseTxoAnomaly] let emittedDetails: [DatabaseTxoAnomaly] let truncatedCount: Int + /// Per-reason totals, computed once from the grouping the truncation + /// already needed: the summary asks eight times, on the held queue. + let countsByReason: [String: Int] func count(reason: String) -> Int { - details.filter { $0.reason == reason }.count + countsByReason[reason] ?? 0 } } @@ -437,7 +440,12 @@ enum CoreWalletDiagnosticAnalyzer { emitted.append(contentsOf: rows.prefix(CoreDiagnosticConstants.detailLimit)) truncated += max(0, rows.count - CoreDiagnosticConstants.detailLimit) } - return .init(details: details, emittedDetails: emitted, truncatedCount: truncated) + return .init( + details: details, + emittedDetails: emitted, + truncatedCount: truncated, + countsByReason: grouped.mapValues(\.count) + ) } /// Value and spent state of a shielded note; identifiers are unnecessary diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index e278aad7611..d7931ffc65a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -472,6 +472,12 @@ public class PlatformWalletManager: ObservableObject { /// not make synchronous create/load/delete operations unsafe and therefore /// must not participate in `ensureSyncNativeOpAllowed`. private var activeCoreDiagnosticsNativeOpCount = 0 + /// Set by `shutdown()` before it drains `activeCoreDiagnosticsNativeOpCount`. + /// A diagnostic pass checks it before every FFI read, so the drain waits + /// for at most the one read already in flight — never for the rest of an + /// export — and a support export can never outlive the process it is + /// diagnosing. Never reset: a manager is shut down once. + let coreDiagnosticsCancellation = CoreDiagnosticsCancellation() private var nativeOpDrainContinuations: [CheckedContinuation] = [] /// Admission + bookkeeping shared by the async native entrypoints: @@ -678,6 +684,7 @@ public class PlatformWalletManager: ObservableObject { ranOffMainThread: false) } shutdownRequested = true + coreDiagnosticsCancellation.cancel() if activeNativeOpCount == 0, activeCoreDiagnosticsNativeOpCount == 0 { break } await withCheckedContinuation { continuation in nativeOpDrainContinuations.append(continuation) @@ -2478,50 +2485,56 @@ public class PlatformWalletManager: ObservableObject { return [] } + switch Self.readAccountBalances(handle: handle, walletId: walletId) { + case .success(let balances): + return balances + case .failure(let error): + self.lastError = error + return [] + } + } + + + /// The one place `platform_wallet_manager_get_account_balances` is called + /// and its entries copied out. `nonisolated static` so the read-only + /// diagnostics, which run off the main actor, share it instead of carrying + /// a second copy that would report stale fields the day + /// `AccountBalanceEntryFFI` gains one. Frees the Rust allocation on every + /// successful non-empty path. + nonisolated static func readAccountBalances( + handle: Handle, + walletId: Data + ) -> Result<[AccountBalance], PlatformWalletError> { var outEntries: UnsafePointer? var outCount: UInt = 0 - - let ffiResult = walletId.withUnsafeBytes { (raw: UnsafeRawBufferPointer) -> PlatformWalletFFIResult in - let base = raw.baseAddress?.assumingMemoryBound(to: UInt8.self) - return platform_wallet_manager_get_account_balances( + let ffi = walletId.withUnsafeBytes { raw in + platform_wallet_manager_get_account_balances( handle, - base, + raw.baseAddress?.assumingMemoryBound(to: UInt8.self), &outEntries, &outCount ) } - - let result = PlatformWalletResult(ffiResult) - - - guard result.isSuccess else { - self.lastError = PlatformWalletError(result: result) - return [] - } - - guard let entries = outEntries, outCount > 0 else { - return [] - } - + let result = PlatformWalletResult(ffi) + guard result.isSuccess else { return .failure(PlatformWalletError(result: result)) } + guard let entries = outEntries, outCount > 0 else { return .success([]) } defer { platform_wallet_manager_free_account_balances( - UnsafeMutablePointer(mutating: entries), - outCount + UnsafeMutablePointer(mutating: entries), outCount ) } - - return (0.. CoreWalletDatabaseDiagnosticSnapshot? { await withCheckedContinuation { continuation in @@ -212,7 +227,6 @@ extension PlatformWalletPersistenceHandler { let snapshot = autoreleasepool { () -> CoreWalletDatabaseDiagnosticSnapshot? in return emitCoreWalletDatabaseDiagnosticsOnQueue( walletId: walletId, - checkpoint: checkpoint, limits: limits ) } @@ -227,9 +241,12 @@ extension PlatformWalletPersistenceHandler { @discardableResult func emitCoreWalletDatabaseDiagnosticsOnQueue( walletId: Data, - checkpoint: CoreWalletDiagnosticCheckpoint, limits: CoreDiagnosticRowLimits = .production ) -> CoreWalletDatabaseDiagnosticSnapshot? { + // This whole pass is export-only: the launch restore path takes + // `logCoreRestoreBufferSnapshotOnQueue` and never comes here, so the + // checkpoint every event below carries is a constant, not a parameter. + let checkpoint = CoreWalletDiagnosticCheckpoint.preExport do { let walletDescriptor = FetchDescriptor( predicate: PersistentWallet.predicate(walletId: walletId) @@ -276,45 +293,30 @@ extension PlatformWalletPersistenceHandler { // intended to diagnose rather than reproduce. let allTransactions: [PersistentTransaction]? let walletTransactions: [PersistentTransaction]? - if checkpoint == .preExport { - do { - let transactionRowCount = try backgroundContext.fetchCount( + do { + let transactionRowCount = try backgroundContext.fetchCount( + FetchDescriptor() + ) + // Both tables must fit: the audit resolves each decoded + // output against `txoByOutpoint`, so a wallet-only TXO + // scan would turn every foreign row into `missing_txo`. + if crossWalletTxoScan, + transactionRowCount <= limits.exactAuditTransactionRows { + allTransactions = try backgroundContext.fetch( FetchDescriptor() ) - // Both tables must fit: the audit resolves each decoded - // output against `txoByOutpoint`, so a wallet-only TXO - // scan would turn every foreign row into `missing_txo`. - if crossWalletTxoScan, - transactionRowCount <= limits.exactAuditTransactionRows { - let fetched = try backgroundContext.fetch( - FetchDescriptor() - ) - allTransactions = fetched - walletTransactions = fetched.filter { - Self.walletOwnsTransaction(walletId: walletId, transaction: $0) - } - } else { - allTransactions = nil - walletTransactions = nil - SDKLogger.event( - "core_owned_output_audit_summary", - category: .persistence, - severity: .warning, - fields: [ - "audit_incomplete": .boolean(true), - "checkpoint": .publicText(checkpoint.rawValue), - "reason": .publicText("tables_too_large_for_exact_audit"), - "transaction_row_count": .integer(Int64(transactionRowCount)), - "transaction_row_limit": .integer( - Int64(limits.exactAuditTransactionRows) - ), - "txo_row_count": .integer(Int64(txoRowCount)), - "txo_row_limit": .integer(Int64(limits.crossWalletTxoRows)), - "wallet_reference": .reference(walletId), - ] - ) - } - } catch { + // Through the accounts' inverse relationship, not + // `walletOwnsTransaction` over every row: that faults four + // relationships per transaction and dominated the time the + // queue is held, to feed two counts. This is the + // `involvedAccounts` route only — a row tied to the wallet + // solely through a TXO is not counted, which the field + // names (`involved_…`) say. + var seen = Set() + walletTransactions = wallet.accounts + .flatMap(\.involvedTransactions) + .filter { seen.insert(ObjectIdentifier($0)).inserted } + } else { allTransactions = nil walletTransactions = nil SDKLogger.event( @@ -324,14 +326,31 @@ extension PlatformWalletPersistenceHandler { fields: [ "audit_incomplete": .boolean(true), "checkpoint": .publicText(checkpoint.rawValue), - "reason": .publicText("transaction_fetch_failed"), + "reason": .publicText("tables_too_large_for_exact_audit"), + "transaction_row_count": .integer(Int64(transactionRowCount)), + "transaction_row_limit": .integer( + Int64(limits.exactAuditTransactionRows) + ), + "txo_row_count": .integer(Int64(txoRowCount)), + "txo_row_limit": .integer(Int64(limits.crossWalletTxoRows)), "wallet_reference": .reference(walletId), ] ) } - } else { + } catch { allTransactions = nil walletTransactions = nil + SDKLogger.event( + "core_owned_output_audit_summary", + category: .persistence, + severity: .warning, + fields: [ + "audit_incomplete": .boolean(true), + "checkpoint": .publicText(checkpoint.rawValue), + "reason": .publicText("transaction_fetch_failed"), + "wallet_reference": .reference(walletId), + ] + ) } let pending: [PersistentPendingInput]? do { @@ -396,7 +415,7 @@ extension PlatformWalletPersistenceHandler { diagnosticSaturatingSum(spent.map(\.amount)) ), "synced_height": .unsignedInteger(UInt64(wallet.syncedHeight)), - "transaction_count": .integer( + "involved_transaction_count": .integer( walletTransactions.map { Int64($0.count) } ?? -1 ), "transaction_scan_available": .boolean(walletTransactions != nil), @@ -418,32 +437,73 @@ extension PlatformWalletPersistenceHandler { ] ) - let sortedAccounts = wallet.accounts.sorted { - ($0.accountType, $0.standardTag, $0.accountIndex, - $0.registrationIndex, $0.keyClass) - < ($1.accountType, $1.standardTag, $1.accountIndex, - $1.registrationIndex, $1.keyClass) + let sortedAccounts = wallet.accounts.sorted(by: Self.accountOrder) + // One pass over the wallet's rows, grouped by account, instead of an + // identity filter per account (O(accounts × rows)) followed by five + // more filters per account — all on the held persistence queue. + struct AccountTally { + var spentCount = 0, spentValue: UInt64 = 0 + var unspentCount = 0, unspentValue: UInt64 = 0 + var confirmedCount = 0, confirmedValue: UInt64 = 0 + var unconfirmedCount = 0, unconfirmedValue: UInt64 = 0 + var lockedCount = 0, lockedValue: UInt64 = 0 + var fingerprintMaterial: [Data] = [] + } + var tallies: [ObjectIdentifier: AccountTally] = [:] + var accountKeys: [ObjectIdentifier: CoreWalletDatabaseDiagnosticSnapshot.AccountKey] = [:] + for txo in walletTxos { + // Rows without an account are `missing_account` anomalies, + // reported by `logTxoAnomalies`; no account snapshot owns them. + guard let account = txo.account else { continue } + let id = ObjectIdentifier(account) + let key: CoreWalletDatabaseDiagnosticSnapshot.AccountKey + if let known = accountKeys[id] { + key = known + } else { + key = Self.diagnosticAccountKey(account)! + accountKeys[id] = key + } + var tally = tallies[id, default: AccountTally()] + if txo.isSpent { + tally.spentCount += 1 + tally.spentValue = diagnosticSaturatingAdd(tally.spentValue, txo.amount) + } else { + tally.unspentCount += 1 + tally.unspentValue = diagnosticSaturatingAdd(tally.unspentValue, txo.amount) + } + if txo.isConfirmed { + tally.confirmedCount += 1 + tally.confirmedValue = diagnosticSaturatingAdd(tally.confirmedValue, txo.amount) + } else { + tally.unconfirmedCount += 1 + tally.unconfirmedValue = diagnosticSaturatingAdd(tally.unconfirmedValue, txo.amount) + } + if txo.isLocked { + tally.lockedCount += 1 + tally.lockedValue = diagnosticSaturatingAdd(tally.lockedValue, txo.amount) + } + tally.fingerprintMaterial.append(diagnosticTxoFingerprint( + outpoint: txo.outpoint, + amount: txo.amount, + height: txo.height, + scriptPubKey: txo.scriptPubKey, + isLocked: txo.isLocked, + account: key + )) + tallies[id] = tally } + for account in sortedAccounts { let key = Self.diagnosticAccountKey(account)! - let accountTxos = walletTxos.filter { $0.account === account } - let accountSpent = accountTxos.filter(\.isSpent) - let accountUnspent = accountTxos.filter { !$0.isSpent } - let accountConfirmed = accountTxos.filter(\.isConfirmed) - let accountUnconfirmed = accountTxos.filter { !$0.isConfirmed } - let accountLocked = accountTxos.filter(\.isLocked) - let externalAddresses = account.coreAddresses.filter { $0.poolTypeTag == 0 } - let internalAddresses = account.coreAddresses.filter { $0.poolTypeTag == 1 } - let accountFingerprint = diagnosticFingerprint(accountTxos.map { - diagnosticTxoFingerprint( - outpoint: $0.outpoint, - amount: $0.amount, - height: $0.height, - scriptPubKey: $0.scriptPubKey, - isLocked: $0.isLocked, - account: key - ) - }) + let tally = tallies[ObjectIdentifier(account)] ?? AccountTally() + var externalAddressCount = 0 + var internalAddressCount = 0 + var usedAddressCount = 0 + for address in account.coreAddresses { + if address.poolTypeTag == 0 { externalAddressCount += 1 } + if address.poolTypeTag == 1 { internalAddressCount += 1 } + if address.isUsed { usedAddressCount += 1 } + } SDKLogger.event( "core_db_account_snapshot", category: .persistence, @@ -452,36 +512,24 @@ extension PlatformWalletPersistenceHandler { "account_reference": .reference(key.referenceMaterial), "account_type": .unsignedInteger(UInt64(account.accountType)), "checkpoint": .publicText(checkpoint.rawValue), - "confirmed_count": .integer(Int64(accountConfirmed.count)), - "confirmed_value_duffs": .unsignedInteger( - diagnosticSaturatingSum(accountConfirmed.map(\.amount)) - ), - "external_address_count": .integer(Int64(externalAddresses.count)), + "confirmed_count": .integer(Int64(tally.confirmedCount)), + "confirmed_value_duffs": .unsignedInteger(tally.confirmedValue), + "external_address_count": .integer(Int64(externalAddressCount)), "external_highest_used": .integer(Int64(account.externalHighestUsed)), - "internal_address_count": .integer(Int64(internalAddresses.count)), + "internal_address_count": .integer(Int64(internalAddressCount)), "internal_highest_used": .integer(Int64(account.internalHighestUsed)), - "locked_count": .integer(Int64(accountLocked.count)), - "locked_value_duffs": .unsignedInteger( - diagnosticSaturatingSum(accountLocked.map(\.amount)) - ), + "locked_count": .integer(Int64(tally.lockedCount)), + "locked_value_duffs": .unsignedInteger(tally.lockedValue), "registration_index": .unsignedInteger(UInt64(account.registrationIndex)), - "spent_count": .integer(Int64(accountSpent.count)), - "spent_value_duffs": .unsignedInteger( - diagnosticSaturatingSum(accountSpent.map(\.amount)) - ), + "spent_count": .integer(Int64(tally.spentCount)), + "spent_value_duffs": .unsignedInteger(tally.spentValue), "standard_tag": .unsignedInteger(UInt64(account.standardTag)), - "txo_fingerprint": .reference(accountFingerprint), - "unconfirmed_count": .integer(Int64(accountUnconfirmed.count)), - "unconfirmed_value_duffs": .unsignedInteger( - diagnosticSaturatingSum(accountUnconfirmed.map(\.amount)) - ), - "unspent_count": .integer(Int64(accountUnspent.count)), - "unspent_value_duffs": .unsignedInteger( - diagnosticSaturatingSum(accountUnspent.map(\.amount)) - ), - "used_address_count": .integer( - Int64(account.coreAddresses.filter(\.isUsed).count) - ), + "txo_fingerprint": .reference(diagnosticFingerprint(tally.fingerprintMaterial)), + "unconfirmed_count": .integer(Int64(tally.unconfirmedCount)), + "unconfirmed_value_duffs": .unsignedInteger(tally.unconfirmedValue), + "unspent_count": .integer(Int64(tally.unspentCount)), + "unspent_value_duffs": .unsignedInteger(tally.unspentValue), + "used_address_count": .integer(Int64(usedAddressCount)), "wallet_reference": .reference(walletId), ] ) @@ -496,8 +544,7 @@ extension PlatformWalletPersistenceHandler { // be expensive. The exact #4438 audit is needed for the manually // exported artifact, not for restoring Rust, so keep startup's // persistence queue limited to lightweight summaries. - if checkpoint == .preExport, - let allTransactions { + if let allTransactions { Self.auditCoinJoinOwnedBip44Outputs( wallet: wallet, walletId: walletId, @@ -690,6 +737,13 @@ extension PlatformWalletPersistenceHandler { ) } + /// The one ordering every per-account pass uses, so anything that picks + /// "the first account" picks the same one on every export. + private static func accountOrder(_ lhs: PersistentAccount, _ rhs: PersistentAccount) -> Bool { + (lhs.accountType, lhs.standardTag, lhs.accountIndex, lhs.registrationIndex, lhs.keyClass) + < (rhs.accountType, rhs.standardTag, rhs.accountIndex, rhs.registrationIndex, rhs.keyClass) + } + /// Read the relationship-owned wallet independently of the denormalized /// `PersistentTxo.walletId`. Diagnostics must compare the two sources; /// `resolvedWalletId(of:)` deliberately prefers the denormalized value and @@ -798,8 +852,15 @@ extension PlatformWalletPersistenceHandler { else { return nil } return txo.outpoint }) + // `wallet.accounts` is unordered. Two BIP44 accounts holding a row for + // the same address would otherwise make `expectedAccount` — and so + // `wrong_account` — depend on which faulted first; the same ordering + // the account snapshots use makes the winner the same on every run. var bip44Addresses: [String: PersistentAccount] = [:] - for account in wallet.accounts where account.accountType == 0 && account.standardTag == 0 { + let bip44Accounts = wallet.accounts + .filter { $0.accountType == 0 && $0.standardTag == 0 } + .sorted(by: Self.accountOrder) + for account in bip44Accounts { for coreAddress in account.coreAddresses where bip44Addresses[coreAddress.address] == nil { bip44Addresses[coreAddress.address] = account } @@ -808,6 +869,7 @@ extension PlatformWalletPersistenceHandler { var candidateCount = 0 var decodeFailureCount = 0 + var transactionBytesMissingCount = 0 var ownedOutputCount = 0 var ownedOutputValue: UInt64 = 0 var unattributedOutputCount = 0 @@ -830,7 +892,20 @@ extension PlatformWalletPersistenceHandler { return } - for transaction in allTransactions where !transaction.transactionData.isEmpty { + for transaction in allTransactions { + // A stub row with no consensus bytes is a real production state + // (an orphaned upsert reads back as empty) — and the half-written + // persistence #4438 is about. It cannot be decoded, so it cannot + // become a candidate; it must be counted rather than skipped, or + // the export reports a clean wallet with `audit_incomplete=false`. + // Stubs are rare, so the ownership check is cheap here even though + // it is the expensive one. + if transaction.transactionData.isEmpty { + if walletOwnsTransaction(walletId: walletId, transaction: transaction) { + transactionBytesMissingCount += 1 + } + continue + } let decoded: DecodedTransaction do { decoded = try TransactionDecoder.decode(transaction.transactionData, network: network) @@ -932,9 +1007,12 @@ extension PlatformWalletPersistenceHandler { SDKLogger.event( "core_owned_output_audit_summary", category: .persistence, - severity: anomalies.isEmpty && decodeFailureCount == 0 ? .info : .warning, + severity: anomalies.isEmpty && decodeFailureCount == 0 + && transactionBytesMissingCount == 0 ? .info : .warning, fields: [ - "audit_incomplete": .boolean(decodeFailureCount > 0), + "audit_incomplete": .boolean( + decodeFailureCount > 0 || transactionBytesMissingCount > 0 + ), "bip44_address_pool_size": .integer(Int64(bip44Addresses.count)), "candidate_transaction_count": .integer(Int64(candidateCount)), "checkpoint": .publicText(checkpoint.rawValue), @@ -948,6 +1026,7 @@ extension PlatformWalletPersistenceHandler { "owned_bip44_output_value_duffs": .unsignedInteger(ownedOutputValue), "persisted_valid_count": .integer(Int64(validCount)), "total_anomaly_count": .integer(Int64(anomalies.count)), + "transaction_bytes_missing_count": .integer(Int64(transactionBytesMissingCount)), "truncated_count": .integer(Int64(truncatedAnomalyCount)), "unattributed_output_count": .integer(Int64(unattributedOutputCount)), "wallet_reference": .reference(walletId), @@ -1016,6 +1095,16 @@ extension PlatformWalletPersistenceHandler { key.append(relationshipWalletId(of: txo) ?? Data()) key.append(0) withUnsafeBytes(of: txo.amount.littleEndian) { key.append(contentsOf: $0) } + // Two rows ours by id with the same amount and script but different + // accounts (BIP44 vs CoinJoin) must not tie: `sorted` is not stable, + // so a tie would make `wrong_account` depend on fetch order. + if let account = diagnosticAccountKey(txo.account) { + key.append(1) + key.append(account.referenceMaterial) + } else { + key.append(0) + } + key.append(0) key.append(txo.scriptPubKey) return key } @@ -1036,7 +1125,7 @@ extension PlatformWalletPersistenceHandler { category: .persistence, fields: [ "checkpoint": .publicText(checkpoint.rawValue), - "core_type_8_transaction_count": .integer( + "involved_type_8_transaction_count": .integer( walletTransactions.map { Int64($0.filter(\.isAssetLock).count) } ?? -1 ), "core_transaction_scan_available": .boolean(walletTransactions != nil), @@ -1157,17 +1246,12 @@ extension PlatformWalletManager { /// says so in `core_owned_output_audit_summary`. A paged variant that /// lifts the ceilings without losing the classification is tracked as a /// follow-up. - public func emitCoreWalletDiagnostics(for walletId: Data) async { - await emitCoreWalletDiagnostics(for: walletId, checkpoint: .preExport) - } - + /// /// Coordinates the queue-owned SwiftData snapshot with read-only Rust FFI /// queries. Admission happens after the database await, then keeps the /// native handle alive until the off-main worker finishes. - private func emitCoreWalletDiagnostics( - for walletId: Data, - checkpoint: CoreWalletDiagnosticCheckpoint - ) async { + public func emitCoreWalletDiagnostics(for walletId: Data) async { + let checkpoint = CoreWalletDiagnosticCheckpoint.preExport guard walletId.count == 32, let handler = persistence else { SDKLogger.event( "core_diagnostics_unavailable", @@ -1181,10 +1265,7 @@ extension PlatformWalletManager { ) return } - let database = await handler.emitCoreWalletDatabaseDiagnostics( - walletId: walletId, - checkpoint: checkpoint - ) + let database = await handler.emitCoreWalletDatabaseDiagnostics(walletId: walletId) guard let database else { return } // The DB await above lets shutdown interleave. Admission is atomic on // MainActor and keeps the copied handle alive across the off-main FFI @@ -1221,13 +1302,15 @@ extension PlatformWalletManager { let managerHandle = handle let managedWallet = wallets[walletId] + let cancellation = coreDiagnosticsCancellation await withCheckedContinuation { continuation in Self.coreDiagnosticsQueue.async { Self.emitCoreMemoryDiagnostics( managerHandle: managerHandle, managedWallet: managedWallet, database: database, - checkpoint: checkpoint + checkpoint: checkpoint, + cancellation: cancellation ) continuation.resume() } @@ -1241,18 +1324,41 @@ extension PlatformWalletManager { managerHandle: Handle, managedWallet: ManagedPlatformWallet?, database: CoreWalletDatabaseDiagnosticSnapshot, - checkpoint: CoreWalletDiagnosticCheckpoint + checkpoint: CoreWalletDiagnosticCheckpoint, + cancellation: CoreDiagnosticsCancellation ) { + // Each FFI read below can park this thread on a Rust lock for as long + // as a wedged sync pass holds it, and `shutdown()` waits on this + // pass's admission. So before every read, ask whether shutdown has + // begun; if so, say which reads were skipped and let the drain go. + func shutdownBegan(before stage: String) -> Bool { + guard cancellation.isCancelled else { return false } + SDKLogger.event( + "core_memory_snapshot_unavailable", + category: .persistence, + severity: .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "reason": .publicText("shutdown_requested"), + "skipped_from_stage": .publicText(stage), + "wallet_reference": .reference(database.walletId), + ] + ) + return true + } + // Keep the two Rust-memory sources independent: corrupt account state // must not suppress the AssetLock evidence that can explain a missing // balance (and vice versa). + if shutdownBegan(before: "asset_locks") { return } compareAssetLocks( database, managedWallet: managedWallet, checkpoint: checkpoint ) - let balanceQuery = diagnosticAccountBalances( - managerHandle: managerHandle, + if shutdownBegan(before: "account_balances") { return } + let balanceQuery = readAccountBalances( + handle: managerHandle, walletId: database.walletId ) guard case .success(let balances) = balanceQuery else { @@ -1278,6 +1384,7 @@ extension PlatformWalletManager { } for balance in sortedBalances { let key = Self.diagnosticAccountKey(balance) + if shutdownBegan(before: "account_utxos") { return } let query = diagnosticAccountUtxos( managerHandle: managerHandle, walletId: database.walletId, @@ -1565,52 +1672,6 @@ extension PlatformWalletManager { } } - /// Copies the Rust-owned account-balance array into Swift values and frees - /// the FFI allocation on every successful non-empty path. - private nonisolated static func diagnosticAccountBalances( - managerHandle: Handle, - walletId: Data - ) -> Result<[AccountBalance], PlatformWalletError> { - var outEntries: UnsafePointer? - var outCount: UInt = 0 - let ffi = walletId.withUnsafeBytes { raw in - platform_wallet_manager_get_account_balances( - managerHandle, - raw.baseAddress?.assumingMemoryBound(to: UInt8.self), - &outEntries, - &outCount - ) - } - let result = PlatformWalletResult(ffi) - guard result.isSuccess else { return .failure(PlatformWalletError(result: result)) } - guard let entries = outEntries, outCount > 0 else { return .success([]) } - defer { - platform_wallet_manager_free_account_balances( - UnsafeMutablePointer(mutating: entries), outCount - ) - } - return .success((0.. Date: Mon, 7 Sep 2026 15:54:28 +0200 Subject: [PATCH 08/17] fix(swift-sdk): bound the migration fallback to the entities known to have drifted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A newer build that adds an attribute to an existing entity and keeps its version identifier leaves a store with the same entity names as ours and one differing hash — which, to the version-identifier and unknown-entity checks, looks exactly like a drifted v4.2.0-dev.1 store. The fallback then opened it with inferred migration and dropped the attribute's values. `classifyStore` now compares the store's per-entity hashes against the model of the version the store declares, and the fallback runs only if every disagreeing entity is in `knownDriftedEntities`: the two shapes changed in place since V1 (`PersistentDocumentType`, `PersistentIndex`), which is why a dev.1 store fails its checksum at all. A disagreement anywhere else is `newerThanRegistered(reason: "unexpected_entity_drift=…")` and is rethrown, with the verdict on the failure event. The decision is a pure function (`storeSchemaVerdict`) so every branch is tested on plain values; `testKnownDriftedEntitiesArePinnedToTheFixture` asserts the allowlist equals the fixture's actual disagreeing set, so it cannot be wider than reality and shrinks as shapes get frozen; and `testStoreWithAnAttributeOnlyNewerEntityIsRefusedWithoutFallback` writes a real store through a `VersionedSchema` that keeps V3's identifier and clones `PersistentWalletManagerMetadata` with one extra attribute, then asserts it is refused untouched. What remains, stated on `storeSchemaVerdict`: a newer build that changed only one of those two already-drifted entities still reads as drift. That is as narrow as metadata allows; freezing the two shapes removes it. Co-Authored-By: Claude Fable 5.1 --- .../Persistence/DashModelContainer.swift | 103 +++++++++--- .../Dev1StoreUpgradeTests.swift | 152 ++++++++++++++++++ 2 files changed, 235 insertions(+), 20 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 153e5f439e4..4957e61a94d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -69,14 +69,31 @@ public enum DashModelContainer { } } - /// Classifies the store from its metadata alone; never opens it. + /// Entities whose live shape has changed in place since the registered + /// version a still-supported store was written with — the reason a + /// v4.2.0-dev.1 store no longer matches V1's checksum although it was + /// written by V1. A store is "drifted" only if the entities whose hashes + /// disagree with its declared version's model are all in this set; a + /// disagreement anywhere else can only have been written by a newer + /// build, and inferred migration would silently remove what it wrote. /// - /// Known blind spot, recorded rather than hidden: a newer build that added - /// only an *attribute* to an existing entity and kept the version - /// identifier is indistinguishable here from drift, because both leave - /// the same entity names with different hashes. Freezing the remaining - /// shapes in `DashSchemaFrozenModels.swift` is what closes that, by - /// making every registered version's hashes stable. + /// `Dev1StoreUpgradeTests` pins this to the fixture, so it cannot be + /// wider than reality. Shrink it as shapes get frozen in + /// `DashSchemaFrozenModels.swift`; when it is empty the fallback has no + /// case left to answer and can go. + static let knownDriftedEntities: Set = [ + "PersistentDocumentType", + "PersistentIndex", + ] + + /// One registered version as the verdict sees it: its identifier and + /// the per-entity hashes of the model built from its live types. + struct RegisteredVersionHashes: Equatable { + let identifier: String + let entityHashes: [String: Data] + } + + /// Classifies the store from its metadata alone; never opens it. static func classifyStore(at storeURL: URL) -> StoreSchemaVerdict { guard let metadata = try? NSPersistentStoreCoordinator.metadataForPersistentStore( ofType: NSSQLiteStoreType, @@ -84,32 +101,78 @@ public enum DashModelContainer { options: nil ) else { return .unreadable } - let matches = DashMigrationPlan.schemas.contains { schema in - guard let model = NSManagedObjectModel.makeManagedObjectModel(for: schema.models) - else { return false } - return model.isConfiguration(withName: nil, compatibleWithStoreMetadata: metadata) + let models = DashMigrationPlan.schemas.compactMap { schema -> (String, NSManagedObjectModel)? in + NSManagedObjectModel.makeManagedObjectModel(for: schema.models) + .map { (schema.versionIdentifier.description, $0) } } - if matches { return .matchesRegisteredVersion } + let matches = models.contains { _, model in + model.isConfiguration(withName: nil, compatibleWithStoreMetadata: metadata) + } + return storeSchemaVerdict( + matchesRegisteredVersion: matches, + storeEntityHashes: (metadata[NSStoreModelVersionHashesKey] as? [String: Data]) ?? [:], + storeVersionIdentifiers: (metadata[NSStoreModelVersionIdentifiersKey] as? [String]) ?? [], + registered: models.map { + RegisteredVersionHashes(identifier: $0.0, entityHashes: $0.1.entityVersionHashesByName) + }, + currentEntities: Set(schema.entities.map(\.name)) + ) + } + + /// The decision behind `classifyStore`, on plain values so every branch + /// can be tested without building a store for it. + /// + /// The residual after all four checks, recorded rather than hidden: a + /// newer build that changed only one of `knownDriftedEntities` and kept + /// the version identifier still reads as drift. That is as narrow as + /// metadata allows; freezing those two shapes is what removes it. + static func storeSchemaVerdict( + matchesRegisteredVersion: Bool, + storeEntityHashes: [String: Data], + storeVersionIdentifiers: [String], + registered: [RegisteredVersionHashes], + currentEntities: Set + ) -> StoreSchemaVerdict { + if matchesRegisteredVersion { return .matchesRegisteredVersion } // SwiftData writes each `VersionedSchema.versionIdentifier` into the - // store; one this plan never registered was written by a newer build. - let registered = Set(DashMigrationPlan.schemas.map { $0.versionIdentifier.description }) - let written = (metadata[NSStoreModelVersionIdentifiersKey] as? [String]) ?? [] - if let unknown = written.first(where: { !registered.contains($0) }) { + // store; one this plan never registered was written by a newer build, + // and a store carrying none cannot be placed at all. + let registeredIdentifiers = Set(registered.map(\.identifier)) + if let unknown = storeVersionIdentifiers.first(where: { !registeredIdentifiers.contains($0) }) { return .newerThanRegistered(reason: "unregistered_version_identifier=\(unknown)") } + guard !storeVersionIdentifiers.isEmpty else { + return .newerThanRegistered(reason: "no_version_identifier") + } // An entity the current schema does not have can only have been // written by a newer build; inferred migration would drop its table. - let current = Set(schema.entities.map(\.name)) - let stored = Set(((metadata[NSStoreModelVersionHashesKey] as? [String: Any]) ?? [:]).keys) - let unknownEntities = stored.subtracting(current).sorted() + let unknownEntities = Set(storeEntityHashes.keys).subtracting(currentEntities).sorted() if !unknownEntities.isEmpty { return .newerThanRegistered( reason: "unknown_entities=\(unknownEntities.joined(separator: "|"))" ) } - return .driftedRegisteredVersion + + // Same entity names, so which ones disagree with the version the + // store declares? Drift changes only the known set; a newer build + // that added an attribute — same names, kept identifier — changes + // something outside it. Only the declared version's model is a fair + // comparison: later versions legitimately differ from the store. + var unexpectedDrift: Set = [] + for version in registered where storeVersionIdentifiers.contains(version.identifier) { + let disagreeing = Set(storeEntityHashes.compactMap { name, hash in + version.entityHashes[name] == hash ? nil : name + }) + if disagreeing.isSubset(of: knownDriftedEntities) { + return .driftedRegisteredVersion + } + unexpectedDrift.formUnion(disagreeing.subtracting(knownDriftedEntities)) + } + return .newerThanRegistered( + reason: "unexpected_entity_drift=\(unexpectedDrift.sorted().joined(separator: "|"))" + ) } /// Builds the common payload for both sides of the container open. The diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift index 56007510c6a..95e323efc98 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift @@ -1,3 +1,4 @@ +import CoreData import Foundation import SwiftData import XCTest @@ -219,6 +220,121 @@ final class Dev1StoreUpgradeTests: XCTestCase { return XCTFail("a refused open must not rewrite the store") } } + /// The attribute-only downgrade: a store written by a build that added one + /// attribute to `PersistentWalletManagerMetadata` and kept V3's version + /// identifier. Its identifier is registered and every entity name is + /// known, so only the per-entity comparison can tell it from drift — and + /// must, because inferred migration would drop the attribute's values + /// without a word. + func testStoreWithAnAttributeOnlyNewerEntityIsRefusedWithoutFallback() throws { + let storeURL = directory.appendingPathComponent("DashModel.sqlite") + try autoreleasepool { + let newer = Schema(versionedSchema: AttributeOnlyNewerSchema.self) + let configuration = ModelConfiguration( + schema: newer, + url: storeURL, + allowsSave: true, + cloudKitDatabase: .none + ) + let container = try ModelContainer(for: newer, configurations: [configuration]) + let context = ModelContext(container) + context.insert(AttributeOnlyNewerSchema.PersistentWalletManagerMetadata( + networkRaw: 1, + futureAttribute: 42 + )) + try context.save() + } + + XCTAssertEqual( + DashModelContainer.classifyStore(at: storeURL), + .newerThanRegistered(reason: "unexpected_entity_drift=PersistentWalletManagerMetadata") + ) + XCTAssertThrowsError(try DashModelContainer.open(configuration(at: storeURL))) + XCTAssertTrue(try logLines(event: "core_store_staged_migration_failed").isEmpty) + let result = try XCTUnwrap(try logLines(event: "core_store_open_result").last) + XCTAssertTrue(result.contains(#"result="failure""#), result) + XCTAssertTrue( + result.contains("store_verdict=\"newer_than_registered:unexpected_entity_drift=PersistentWalletManagerMetadata\""), + result + ) + } + + /// `knownDriftedEntities` must be exactly what the fixture shows, no + /// wider: every entity it names is one whose hash disagrees with V1's + /// model for this store, and none disagrees that it does not name. When + /// a shape gets frozen, this is the test that says to shrink the set. + func testKnownDriftedEntitiesArePinnedToTheFixture() throws { + let storeURL = try dev1Configuration(named: "Pin.sqlite").url + let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore( + ofType: NSSQLiteStoreType, at: storeURL, options: nil + ) + let storeHashes = try XCTUnwrap(metadata[NSStoreModelVersionHashesKey] as? [String: Data]) + let identifiers = try XCTUnwrap(metadata[NSStoreModelVersionIdentifiersKey] as? [String]) + XCTAssertEqual(identifiers, [DashSchemaV1.versionIdentifier.description]) + + let v1 = try XCTUnwrap(NSManagedObjectModel.makeManagedObjectModel(for: DashSchemaV1.models)) + let disagreeing = Set(storeHashes.compactMap { name, hash in + v1.entityVersionHashesByName[name] == hash ? nil : name + }) + XCTAssertEqual( + disagreeing, DashModelContainer.knownDriftedEntities, + "fixture drifts on \(disagreeing.sorted()); the allowlist must match exactly" + ) + } + + /// The decision on plain values: which stores the fallback may answer. + func testStoreSchemaVerdictOnPlainValues() { + let a = Data([1]), b = Data([2]) + let current: Set = ["PersistentWallet", "PersistentDocumentType", "PersistentIndex"] + let v1 = DashModelContainer.RegisteredVersionHashes( + identifier: "1.0.0", + entityHashes: ["PersistentWallet": a, "PersistentDocumentType": a, "PersistentIndex": a] + ) + func verdict( + _ store: [String: Data], + identifiers: [String] = ["1.0.0"], + matches: Bool = false + ) -> DashModelContainer.StoreSchemaVerdict { + DashModelContainer.storeSchemaVerdict( + matchesRegisteredVersion: matches, + storeEntityHashes: store, + storeVersionIdentifiers: identifiers, + registered: [v1], + currentEntities: current + ) + } + + // A compatible store is never inspected further. + XCTAssertEqual(verdict(["PersistentWallet": b], matches: true), .matchesRegisteredVersion) + // Drift confined to the known set may be migrated. + XCTAssertEqual( + verdict(["PersistentWallet": a, "PersistentDocumentType": b, "PersistentIndex": b]), + .driftedRegisteredVersion + ) + // The attribute-only downgrade: same names, kept identifier, but the + // disagreement is on an entity that is not known to have drifted. + XCTAssertEqual( + verdict(["PersistentWallet": b, "PersistentDocumentType": a, "PersistentIndex": a]), + .newerThanRegistered(reason: "unexpected_entity_drift=PersistentWallet") + ) + // Mixed: known drift plus one unexpected entity still refuses. + XCTAssertEqual( + verdict(["PersistentWallet": b, "PersistentDocumentType": b, "PersistentIndex": a]), + .newerThanRegistered(reason: "unexpected_entity_drift=PersistentWallet") + ) + XCTAssertEqual( + verdict(["PersistentWallet": a], identifiers: ["9.0.0"]), + .newerThanRegistered(reason: "unregistered_version_identifier=9.0.0") + ) + XCTAssertEqual( + verdict(["PersistentWallet": a], identifiers: []), + .newerThanRegistered(reason: "no_version_identifier") + ) + XCTAssertEqual( + verdict(["PersistentWallet": a, "FutureOnlyModel": a]), + .newerThanRegistered(reason: "unknown_entities=FutureOnlyModel") + ) + } } /// An entity no registered SDK schema has — what a store written by a future @@ -231,3 +347,39 @@ final class FutureOnlyModel { self.marker = marker } } + +/// V3 exactly as a newer build would write it: the same version identifier +/// and the same entity set, with one attribute added to a relationship-free +/// entity. Nested so the clone shares the live entity's name (SwiftData +/// derives it from the unqualified type name) without touching the live type. +enum AttributeOnlyNewerSchema: VersionedSchema { + static var versionIdentifier: Schema.Version { DashSchemaV3.versionIdentifier } + + static var models: [any PersistentModel.Type] { + DashModelContainer.modelTypes.filter { + ObjectIdentifier($0) != ObjectIdentifier(SwiftDashSDK.PersistentWalletManagerMetadata.self) + } + [PersistentWalletManagerMetadata.self] + } + + @Model + final class PersistentWalletManagerMetadata { + @Attribute(.unique) var networkRaw: UInt32 + var combinedSyncHeight: UInt32 + var combinedSyncBlockHash: Data? + var walletCount: Int + var createdAt: Date + var lastUpdated: Date + /// The one thing this build has that the SDK's model does not. + var futureAttribute: Int + + init(networkRaw: UInt32, futureAttribute: Int) { + self.networkRaw = networkRaw + self.combinedSyncHeight = 0 + self.combinedSyncBlockHash = nil + self.walletCount = 0 + self.createdAt = Date() + self.lastUpdated = Date() + self.futureAttribute = futureAttribute + } + } +} From fa4b6a70965b0df1a31526785b2865780e7bd85d Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 7 Sep 2026 16:02:14 +0200 Subject: [PATCH 09/17] fix(swift-sdk): pin the drifted entities by hash, not by name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allowlisting `PersistentDocumentType` and `PersistentIndex` by name left one gap: a newer build that changed only one of those two entities and kept its version identifier still read as drift, and inferred migration would then have dropped what it wrote. `knownDriftedEntityHashes` now holds the exact per-entity version hashes a v4.2.0-dev.1 store carries for those two shapes, read from the fixture and pinned byte-for-byte by `testKnownDriftedEntityHashesArePinnedToTheFixture`. `storeSchemaVerdict` yields `driftedRegisteredVersion` only when every entity disagreeing with the declared version's model carries exactly that hash. A hash is a function of the shape, so a newer build's version of any entity — those two included — is refused as `unexpected_entity_drift`. The fallback therefore answers precisely the store the fixture proves and nothing else; there is no same-name-unknown-shape residual left at the metadata level. Freezing the two shapes remains the right end state, since it lets the staged plan open dev.1 stores directly and retires the fallback, but no data-loss path stays open until then. Co-Authored-By: Claude Fable 5.1 --- .../Persistence/DashModelContainer.swift | 70 +++++++++++-------- .../Dev1StoreUpgradeTests.swift | 38 +++++++--- 2 files changed, 68 insertions(+), 40 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 4957e61a94d..0ab1f3513df 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -49,8 +49,9 @@ public enum DashModelContainer { /// open it, so a failure to do so is something else entirely. case matchesRegisteredVersion /// Written by a registered version whose live models have since - /// drifted (every v4.2.0-dev.1 store, until the remaining V1/V2 - /// shapes are frozen). Inferred migration may open it. + /// drifted, with exactly the drifted shapes `knownDriftedEntityHashes` + /// lists (every v4.2.0-dev.1 store, until the remaining V1/V2 shapes + /// are frozen). Inferred migration may open it. case driftedRegisteredVersion /// Written by a build this SDK does not know — a version identifier /// it never registered, or an entity its schema lacks. Inferred @@ -69,21 +70,25 @@ public enum DashModelContainer { } } - /// Entities whose live shape has changed in place since the registered - /// version a still-supported store was written with — the reason a - /// v4.2.0-dev.1 store no longer matches V1's checksum although it was - /// written by V1. A store is "drifted" only if the entities whose hashes - /// disagree with its declared version's model are all in this set; a - /// disagreement anywhere else can only have been written by a newer - /// build, and inferred migration would silently remove what it wrote. + /// The exact per-entity version hashes a v4.2.0-dev.1 store carries for + /// the two shapes changed in place since V1 — the reason such a store no + /// longer matches V1's checksum although V1 wrote it. /// - /// `Dev1StoreUpgradeTests` pins this to the fixture, so it cannot be - /// wider than reality. Shrink it as shapes get frozen in - /// `DashSchemaFrozenModels.swift`; when it is empty the fallback has no - /// case left to answer and can go. - static let knownDriftedEntities: Set = [ - "PersistentDocumentType", - "PersistentIndex", + /// A store is "drifted" only if every entity whose hash disagrees with its + /// declared version's model carries EXACTLY the hash listed here. The hash + /// is a function of the shape, so any other shape of these two entities — + /// a newer build's, with an added attribute — has a different hash and is + /// refused, and so is a disagreement on any other entity. That is what + /// makes the fallback answer precisely the store the fixture proves and + /// nothing else: there is no "same name, unknown shape" residual left. + /// + /// `Dev1StoreUpgradeTests` pins these to the fixture. Extend only with a + /// hash read from a real store of a supported prerelease; shrink as the + /// shapes get frozen in `DashSchemaFrozenModels.swift`, after which the + /// fallback has no case left to answer and can go. + static let knownDriftedEntityHashes: [String: Data] = [ + "PersistentDocumentType": Data(base64Encoded: "w2iUSIQfuddeVRUyE/lgUE7oObvG1pWzO1Ah2IB2xBs=")!, + "PersistentIndex": Data(base64Encoded: "iJRVIyu7GslKt5zO+2oa194YXGtujJqnzcAe1FPWWa8=")!, ] /// One registered version as the verdict sees it: its identifier and @@ -120,12 +125,13 @@ public enum DashModelContainer { } /// The decision behind `classifyStore`, on plain values so every branch - /// can be tested without building a store for it. - /// - /// The residual after all four checks, recorded rather than hidden: a - /// newer build that changed only one of `knownDriftedEntities` and kept - /// the version identifier still reads as drift. That is as narrow as - /// metadata allows; freezing those two shapes is what removes it. + /// can be tested without building a store for it. Four checks, in order: + /// a registered version is compatible; the declared version identifier + /// is one this plan registered; every entity is one the current schema + /// has; and every entity disagreeing with the declared version's model + /// carries the one hash `knownDriftedEntityHashes` lists for it. Only + /// the last yields `driftedRegisteredVersion`; everything else that is + /// not a match is refused. static func storeSchemaVerdict( matchesRegisteredVersion: Bool, storeEntityHashes: [String: Data], @@ -156,19 +162,23 @@ public enum DashModelContainer { } // Same entity names, so which ones disagree with the version the - // store declares? Drift changes only the known set; a newer build - // that added an attribute — same names, kept identifier — changes - // something outside it. Only the declared version's model is a fair - // comparison: later versions legitimately differ from the store. + // store declares — and is each disagreeing shape the one known drift? + // A newer build that added an attribute keeps the name and the + // identifier but not the hash, whether it touched one of the two + // drifted entities or any other. Only the declared version's model + // is a fair comparison: later versions legitimately differ. var unexpectedDrift: Set = [] for version in registered where storeVersionIdentifiers.contains(version.identifier) { - let disagreeing = Set(storeEntityHashes.compactMap { name, hash in - version.entityHashes[name] == hash ? nil : name + let disagreeing = storeEntityHashes.filter { name, hash in + version.entityHashes[name] != hash + } + let unknownShapes = Set(disagreeing.compactMap { name, hash in + knownDriftedEntityHashes[name] == hash ? nil : name }) - if disagreeing.isSubset(of: knownDriftedEntities) { + if unknownShapes.isEmpty { return .driftedRegisteredVersion } - unexpectedDrift.formUnion(disagreeing.subtracting(knownDriftedEntities)) + unexpectedDrift.formUnion(unknownShapes) } return .newerThanRegistered( reason: "unexpected_entity_drift=\(unexpectedDrift.sorted().joined(separator: "|"))" diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift index 95e323efc98..f4e76473383 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift @@ -259,11 +259,12 @@ final class Dev1StoreUpgradeTests: XCTestCase { ) } - /// `knownDriftedEntities` must be exactly what the fixture shows, no - /// wider: every entity it names is one whose hash disagrees with V1's - /// model for this store, and none disagrees that it does not name. When - /// a shape gets frozen, this is the test that says to shrink the set. - func testKnownDriftedEntitiesArePinnedToTheFixture() throws { + /// `knownDriftedEntityHashes` must be exactly what the fixture shows, no + /// wider and byte for byte: every entity it names disagrees with V1's + /// model for this store, none disagrees that it does not name, and each + /// listed hash is the one the store carries. When a shape gets frozen, + /// this is the test that says to shrink the table. + func testKnownDriftedEntityHashesArePinnedToTheFixture() throws { let storeURL = try dev1Configuration(named: "Pin.sqlite").url let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore( ofType: NSSQLiteStoreType, at: storeURL, options: nil @@ -276,10 +277,14 @@ final class Dev1StoreUpgradeTests: XCTestCase { let disagreeing = Set(storeHashes.compactMap { name, hash in v1.entityVersionHashesByName[name] == hash ? nil : name }) + let known = DashModelContainer.knownDriftedEntityHashes XCTAssertEqual( - disagreeing, DashModelContainer.knownDriftedEntities, - "fixture drifts on \(disagreeing.sorted()); the allowlist must match exactly" + disagreeing, Set(known.keys), + "fixture drifts on \(disagreeing.sorted()); the table must name exactly those" ) + for (name, hash) in known { + XCTAssertEqual(storeHashes[name], hash, "\(name): the pinned hash must be the store's") + } } /// The decision on plain values: which stores the fallback may answer. @@ -306,11 +311,24 @@ final class Dev1StoreUpgradeTests: XCTestCase { // A compatible store is never inspected further. XCTAssertEqual(verdict(["PersistentWallet": b], matches: true), .matchesRegisteredVersion) - // Drift confined to the known set may be migrated. + // Drift confined to the known entities WITH their known shapes may be + // migrated; the same entities with any other shape may not. + let knownDocumentType = DashModelContainer.knownDriftedEntityHashes["PersistentDocumentType"]! + let knownIndex = DashModelContainer.knownDriftedEntityHashes["PersistentIndex"]! XCTAssertEqual( - verdict(["PersistentWallet": a, "PersistentDocumentType": b, "PersistentIndex": b]), + verdict(["PersistentWallet": a, "PersistentDocumentType": knownDocumentType, "PersistentIndex": knownIndex]), .driftedRegisteredVersion ) + XCTAssertEqual( + verdict(["PersistentWallet": a, "PersistentDocumentType": knownDocumentType, "PersistentIndex": a]), + .driftedRegisteredVersion, + "one drifted entity with its known shape, the other untouched" + ) + XCTAssertEqual( + verdict(["PersistentWallet": a, "PersistentDocumentType": b, "PersistentIndex": knownIndex]), + .newerThanRegistered(reason: "unexpected_entity_drift=PersistentDocumentType"), + "a known entity with an unknown shape is a newer build, not drift" + ) // The attribute-only downgrade: same names, kept identifier, but the // disagreement is on an entity that is not known to have drifted. XCTAssertEqual( @@ -319,7 +337,7 @@ final class Dev1StoreUpgradeTests: XCTestCase { ) // Mixed: known drift plus one unexpected entity still refuses. XCTAssertEqual( - verdict(["PersistentWallet": b, "PersistentDocumentType": b, "PersistentIndex": a]), + verdict(["PersistentWallet": b, "PersistentDocumentType": knownDocumentType, "PersistentIndex": a]), .newerThanRegistered(reason: "unexpected_entity_drift=PersistentWallet") ) XCTAssertEqual( From 7fd84c0bdbc3760ccfb2fce4168f1d74b5f3d0e6 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 7 Sep 2026 18:32:26 +0200 Subject: [PATCH 10/17] feat(swift-sdk): surface a refused newer-build store as a typed error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DashModelContainer.open` refuses a store written by a newer build rather than opening it with inferred migration, which would drop what that build wrote. Until now the refusal rethrew SwiftData's opaque `loadIssueModelContainer`, which a host cannot tell from a corrupt file — so it had nothing to say to the user beyond "setup failed". It now throws `DashModelContainerError.storeFromNewerBuild(reason:)`, with an `errorDescription` that says what happened and the two ways forward (update the app, or reset the wallet). Unreadable stores, and stores that match a registered version but failed anyway, still rethrow SwiftData's own error untouched. The Dev1 tests pin the typed error for both newer cases and its absence for the corrupt-file case. Co-Authored-By: Claude Fable 5.1 --- .../Persistence/DashModelContainer.swift | 31 +++++++++++++++++-- .../Dev1StoreUpgradeTests.swift | 21 ++++++++++--- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 0ab1f3513df..87ce376b6be 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -2,12 +2,32 @@ import CoreData import Foundation import SwiftData -/// Why `DashModelContainer.open` refused to open a store. -public enum DashModelContainerError: Error, Equatable { +/// Why `DashModelContainer.open` refused to open a store. Typed so a host can +/// tell a store it must not touch from a store it cannot read, and say the +/// right thing to the user instead of surfacing SwiftData's opaque +/// `loadIssueModelContainer`. +public enum DashModelContainerError: LocalizedError, Equatable { /// The configuration was built from a `Schema` whose entity set differs /// from the SDK's. `unexpected` names entities the SDK schema lacks; /// `missing` names SDK entities the configuration lacks. case schemaMismatch(unexpected: [String], missing: [String]) + /// The store was written by a build with a newer schema than this SDK + /// registers (`reason` says how that was detected). Opening it with + /// inferred migration would silently drop what the newer build wrote, + /// so `open` refuses; the only safe ways forward are a newer build or a + /// wallet reset. + case storeFromNewerBuild(reason: String) + + public var errorDescription: String? { + switch self { + case .schemaMismatch(let unexpected, let missing): + return "The store configuration's schema does not match the SDK schema" + + " (unexpected: \(unexpected.joined(separator: ", ")); missing: \(missing.joined(separator: ", ")))." + case .storeFromNewerBuild: + return "The wallet database on this device was written by a newer version of the app." + + " This version cannot open it without losing data; update the app, or reset the wallet." + } + } } /// Factory for creating SwiftData model containers for Dash Platform persistence @@ -454,6 +474,13 @@ public enum DashModelContainer { : .unreadable guard case .driftedRegisteredVersion = verdict else { report(succeeded: false, migrationPath: .staged, error: error, storeVerdict: verdict) + // A newer build's store is the one refusal the host can act + // on (tell the user to update or reset), so it gets a typed + // error; everything else is SwiftData's own failure, passed + // through untouched. + if case .newerThanRegistered(let reason) = verdict { + throw DashModelContainerError.storeFromNewerBuild(reason: reason) + } throw error } SDKLogger.event( diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift index f4e76473383..c01eb67a299 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift @@ -169,9 +169,12 @@ final class Dev1StoreUpgradeTests: XCTestCase { let storeURL = directory.appendingPathComponent("DashModel.sqlite") try Data(repeating: 0x5A, count: 4096).write(to: storeURL, options: .atomic) - // Unreadable metadata is not a version question. + // Unreadable metadata is not a version question — and not the typed + // "newer build" error either, which would send the user to update. XCTAssertEqual(DashModelContainer.classifyStore(at: storeURL), .unreadable) - XCTAssertThrowsError(try DashModelContainer.open(configuration(at: storeURL))) + XCTAssertThrowsError(try DashModelContainer.open(configuration(at: storeURL))) { error in + XCTAssertNil(error as? DashModelContainerError, "corrupt store must not read as a newer build") + } XCTAssertTrue(try logLines(event: "core_store_staged_migration_failed").isEmpty) let result = try XCTUnwrap(try logLines(event: "core_store_open_result").last) @@ -209,7 +212,12 @@ final class Dev1StoreUpgradeTests: XCTestCase { } XCTAssertTrue(reason.contains("FutureOnlyModel"), reason) - XCTAssertThrowsError(try DashModelContainer.open(configuration(at: storeURL))) + XCTAssertThrowsError(try DashModelContainer.open(configuration(at: storeURL))) { error in + guard case DashModelContainerError.storeFromNewerBuild(let reason) = error else { + return XCTFail("a newer store must surface as the typed error, got \(error)") + } + XCTAssertTrue(reason.contains("FutureOnlyModel"), reason) + } XCTAssertTrue(try logLines(event: "core_store_staged_migration_failed").isEmpty) let result = try XCTUnwrap(try logLines(event: "core_store_open_result").last) XCTAssertTrue(result.contains(#"migration_path="staged""#), result) @@ -249,7 +257,12 @@ final class Dev1StoreUpgradeTests: XCTestCase { DashModelContainer.classifyStore(at: storeURL), .newerThanRegistered(reason: "unexpected_entity_drift=PersistentWalletManagerMetadata") ) - XCTAssertThrowsError(try DashModelContainer.open(configuration(at: storeURL))) + XCTAssertThrowsError(try DashModelContainer.open(configuration(at: storeURL))) { error in + XCTAssertEqual( + error as? DashModelContainerError, + .storeFromNewerBuild(reason: "unexpected_entity_drift=PersistentWalletManagerMetadata") + ) + } XCTAssertTrue(try logLines(event: "core_store_staged_migration_failed").isEmpty) let result = try XCTUnwrap(try logLines(event: "core_store_open_result").last) XCTAssertTrue(result.contains(#"result="failure""#), result) From 668a7077915912d73ac7c5b55860d08242470495 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Mon, 7 Sep 2026 19:19:08 +0200 Subject: [PATCH 11/17] fix(swift-sdk): drain both halves of the export, snapshot committed state only, drop the rescan label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-4 review of the Core wallet diagnostics. Shutdown and cancellation: - Admission is taken BEFORE the database half and released after the Rust half, so `shutdown()`'s drain covers the whole export. A teardown that began during the cross-wallet scan used to proceed while that scan still held the persistence queue every persister callback enters through. - The queue-confined pass takes the cancellation token and checks it before the TXO fetch, the transaction fetch and the owned-output audit, logging what it skipped (`core_diagnostics_unavailable reason=shutdown_requested skipped_from_stage=…`, or an `audit_incomplete` summary at the audit stage) and returning. The drain now waits for at most the stage in flight, on either half. Database pass: - Runs on a scratch `ModelContext(modelContainer)` created inside the `serialQueue.async` block. It sees only COMMITTED state — a Rust `store()` round is one changeset across several separate `sync` blocks, and the pass can land between two of them, where the handler's own context holds pending rows `endChangeset` may still roll back — and it is dropped with the block, so the up-to-110k objects it registers do not stay resident for the life of the process. The queue still guarantees no save lands mid-pass. `backgroundContext` and `onQueue` are private again; the `onQueue` doc that claimed the opposite is gone. - A missing database snapshot (wallet row absent, fetch failed — the very "coins gone from the database" reports this is for) no longer suppresses the Rust half. It runs on the wallet id alone, and both diffs mark the one-sided case with `database_snapshot_available=false` rather than going silent. Logging: - The pre-install buffer keeps its head and drops the newest arrival once full. The store-open line from the host's `init()` is the first in and the one the buffer exists to carry; overflowing with restore and changeset lines must not evict it. Rescan: - `core_rescan_requested` records what was asked and whether the FFI accepted it (`accepted` / `failed` / `invalid_wallet_id`). The pre-call `coreWalletState(for:)` — a Rust-lock FFI read on the main actor, added for a log field — is gone, and so is the rewind label it fed: it compared against the core wallet's synced height, not the filter-scan checkpoint the rescan lowers, so an armed rescan could log `no_op`. The classifier and its test go with it. Co-Authored-By: Claude Fable 5.1 --- .../Core/Services/SDKLogger.swift | 10 +- ...PlatformWalletManagerCoreDiagnostics.swift | 215 +++++++++++++----- .../PlatformWalletManagerSPV.swift | 69 ++---- .../PlatformWalletPersistenceHandler.swift | 19 +- .../CoreWalletDiagnosticAnalyzerTests.swift | 35 --- .../SDKLoggerPreInstallBufferTests.swift | 8 +- 6 files changed, 198 insertions(+), 158 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift index bea5f195f02..d57254b06f6 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift @@ -219,7 +219,11 @@ enum SDKLogFormatter { final class SDKLoggerState: @unchecked Sendable { /// How many pre-install events are retained for replay. A host that never /// installs a sink must not accumulate lines for the life of the process, - /// so the buffer drops its oldest entries and reports the loss instead. + /// so once full the buffer keeps what it has, drops the NEWEST arrivals, + /// and reports the loss. Head-not-tail on purpose: the lines this buffer + /// exists for — `core_store_open_result` from the host's `init()` — are + /// the first ones in, and a launch that overflows does so with restore + /// and changeset lines whose loss costs far less than the store open's. static let pendingLineLimit = 256 private let lock = NSLock() @@ -272,9 +276,9 @@ final class SDKLoggerState: @unchecked Sendable { func record(severity: SDKLogSeverity, line: String) { let destination: SDKLogFileSink? = lock.withLock { guard let sink else { - if pendingLines.count >= Self.pendingLineLimit { - pendingLines.removeFirst() + guard pendingLines.count < Self.pendingLineLimit else { droppedPendingLineCount += 1 + return nil } pendingLines.append((severity: severity, line: line)) return nil diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift index 45c2f5c0a9b..ef0fc8477df 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift @@ -220,14 +220,29 @@ extension PlatformWalletPersistenceHandler { /// resumed across the continuation. func emitCoreWalletDatabaseDiagnostics( walletId: Data, - limits: CoreDiagnosticRowLimits = .production + limits: CoreDiagnosticRowLimits = .production, + cancellation: CoreDiagnosticsCancellation? = nil ) async -> CoreWalletDatabaseDiagnosticSnapshot? { await withCheckedContinuation { continuation in serialQueue.async { [self] in let snapshot = autoreleasepool { () -> CoreWalletDatabaseDiagnosticSnapshot? in + // A scratch context, still on the serial queue. Two things + // the handler's own context could not give: it sees only + // COMMITTED state — a Rust `store()` round is one changeset + // spread across several separate `sync` blocks, and this + // block can land between two of them, where the handler's + // context holds pending rows that `endChangeset` may still + // roll back — and it is dropped with this block, so the up + // to ~110k objects the pass registers do not stay resident + // for the life of the process. The queue still guarantees + // no save lands mid-pass. + let context = ModelContext(modelContainer) + context.autosaveEnabled = false return emitCoreWalletDatabaseDiagnosticsOnQueue( walletId: walletId, - limits: limits + context: context, + limits: limits, + cancellation: cancellation ) } continuation.resume(returning: snapshot) @@ -236,22 +251,43 @@ extension PlatformWalletPersistenceHandler { } /// Queue-confined implementation behind the async export API. Callers must - /// already own `serialQueue`; it performs the full exact audit whenever the - /// tables fit under `limits` and returns only Sendable value copies. + /// already own `serialQueue` and hand in a context confined to it; it + /// performs the full exact audit whenever the tables fit under `limits` + /// and returns only Sendable value copies. Between its stages it asks + /// `cancellation` whether shutdown has begun and, if so, says what it + /// skipped and stops — the drain in `shutdown()` covers this pass, and + /// must never wait for a whole cross-wallet scan. @discardableResult func emitCoreWalletDatabaseDiagnosticsOnQueue( walletId: Data, - limits: CoreDiagnosticRowLimits = .production + context: ModelContext, + limits: CoreDiagnosticRowLimits = .production, + cancellation: CoreDiagnosticsCancellation? = nil ) -> CoreWalletDatabaseDiagnosticSnapshot? { // This whole pass is export-only: the launch restore path takes // `logCoreRestoreBufferSnapshotOnQueue` and never comes here, so the // checkpoint every event below carries is a constant, not a parameter. let checkpoint = CoreWalletDiagnosticCheckpoint.preExport + func shutdownBegan(before stage: String) -> Bool { + guard let cancellation, cancellation.isCancelled else { return false } + SDKLogger.event( + "core_diagnostics_unavailable", + category: .persistence, + severity: .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "reason": .publicText("shutdown_requested"), + "skipped_from_stage": .publicText(stage), + "wallet_reference": .reference(walletId), + ] + ) + return true + } do { let walletDescriptor = FetchDescriptor( predicate: PersistentWallet.predicate(walletId: walletId) ) - guard let wallet = try backgroundContext.fetch(walletDescriptor).first else { + guard let wallet = try context.fetch(walletDescriptor).first else { SDKLogger.event( "core_diagnostics_unavailable", category: .persistence, @@ -274,19 +310,21 @@ extension PlatformWalletPersistenceHandler { // row or a cross-wallet duplicate is then invisible to it. A // streaming pass would lift the ceiling without losing the // distinction and remains the follow-up. - let txoRowCount = try backgroundContext.fetchCount(FetchDescriptor()) + if shutdownBegan(before: "txo_fetch") { return nil } + let txoRowCount = try context.fetchCount(FetchDescriptor()) let crossWalletTxoScan = txoRowCount <= limits.crossWalletTxoRows let allTxos: [PersistentTxo] if crossWalletTxoScan { - allTxos = try backgroundContext.fetch(FetchDescriptor()) + allTxos = try context.fetch(FetchDescriptor()) } else { - allTxos = try backgroundContext.fetch(FetchDescriptor( + allTxos = try context.fetch(FetchDescriptor( predicate: #Predicate { $0.walletId == walletId } )) } let walletTxos = allTxos.filter { $0.walletId == walletId || Self.relationshipWalletId(of: $0) == walletId } + if shutdownBegan(before: "transaction_fetch") { return nil } // Walking every transaction relationship is deliberately export-only. // A heavily mixed wallet can have enough history for this traversal to // stall restore, which is precisely the failure this instrumentation is @@ -294,7 +332,7 @@ extension PlatformWalletPersistenceHandler { let allTransactions: [PersistentTransaction]? let walletTransactions: [PersistentTransaction]? do { - let transactionRowCount = try backgroundContext.fetchCount( + let transactionRowCount = try context.fetchCount( FetchDescriptor() ) // Both tables must fit: the audit resolves each decoded @@ -302,7 +340,7 @@ extension PlatformWalletPersistenceHandler { // scan would turn every foreign row into `missing_txo`. if crossWalletTxoScan, transactionRowCount <= limits.exactAuditTransactionRows { - allTransactions = try backgroundContext.fetch( + allTransactions = try context.fetch( FetchDescriptor() ) // Through the accounts' inverse relationship, not @@ -354,7 +392,7 @@ extension PlatformWalletPersistenceHandler { } let pending: [PersistentPendingInput]? do { - pending = try backgroundContext.fetch( + pending = try context.fetch( FetchDescriptor( predicate: #Predicate { $0.walletId == walletId } ) @@ -544,6 +582,21 @@ extension PlatformWalletPersistenceHandler { // be expensive. The exact #4438 audit is needed for the manually // exported artifact, not for restoring Rust, so keep startup's // persistence queue limited to lightweight summaries. + if let allTransactions, shutdownBegan(before: "owned_output_audit") { + SDKLogger.event( + "core_owned_output_audit_summary", + category: .persistence, + severity: .warning, + fields: [ + "audit_incomplete": .boolean(true), + "checkpoint": .publicText(checkpoint.rawValue), + "reason": .publicText("shutdown_requested"), + "transaction_row_count": .integer(Int64(allTransactions.count)), + "wallet_reference": .reference(walletId), + ] + ) + return nil + } if let allTransactions { Self.auditCoinJoinOwnedBip44Outputs( wallet: wallet, @@ -558,7 +611,7 @@ extension PlatformWalletPersistenceHandler { let assetLocksAvailable: Bool do { assetLocks = try Self.logAssetLockDatabaseSnapshot( - context: backgroundContext, + context: context, walletId: walletId, checkpoint: checkpoint, walletTransactions: walletTransactions @@ -580,7 +633,7 @@ extension PlatformWalletPersistenceHandler { } do { try Self.logShieldedStoreSnapshot( - context: backgroundContext, + context: context, walletId: walletId, checkpoint: checkpoint ) @@ -1265,49 +1318,67 @@ extension PlatformWalletManager { ) return } - let database = await handler.emitCoreWalletDatabaseDiagnostics(walletId: walletId) - guard let database else { return } - // The DB await above lets shutdown interleave. Admission is atomic on - // MainActor and keeps the copied handle alive across the off-main FFI - // work; shutdown drains this operation before consuming the handle. - guard isConfigured, handle != NULL_HANDLE else { - SDKLogger.event( - "core_memory_snapshot_unavailable", - category: .persistence, - severity: .warning, - fields: [ - "checkpoint": .publicText(checkpoint.rawValue), - "reason": .publicText("manager_not_configured_after_database_snapshot"), - "wallet_reference": .reference(walletId), - ] - ) - return + // Admit BEFORE the database half, not after it: `shutdown()`'s drain + // must cover the whole export, or a teardown that begins during the + // cross-wallet scan proceeds while that scan still holds the + // persistence queue every persister callback enters through. The + // queue-confined pass polls `cancellation` between stages, so the + // cover costs the drain at most one stage. A manager with no handle + // has nothing to drain; its database half still runs. + let cancellation = coreDiagnosticsCancellation + let admitted: Bool + if isConfigured, handle != NULL_HANDLE { + do { + try admitCoreDiagnosticsNativeOp() + admitted = true + } catch { + SDKLogger.event( + "core_memory_snapshot_unavailable", + category: .persistence, + severity: .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "reason": .publicText("manager_shutdown_in_progress"), + "wallet_reference": .reference(walletId), + ] + ) + return + } + } else { + admitted = false } - do { - try admitCoreDiagnosticsNativeOp() - } catch { + defer { if admitted { finishCoreDiagnosticsNativeOp() } } + + // The database half may come back empty — wallet row missing, fetch + // failed — and those are exactly the "coins gone from the database" + // reports this exists for. The Rust half needs only the wallet id, so + // it runs regardless and marks its diffs as one-sided. + let database = await handler.emitCoreWalletDatabaseDiagnostics( + walletId: walletId, + cancellation: cancellation + ) + guard admitted else { SDKLogger.event( "core_memory_snapshot_unavailable", category: .persistence, severity: .warning, fields: [ "checkpoint": .publicText(checkpoint.rawValue), - "reason": .publicText("manager_shutdown_in_progress"), + "reason": .publicText("manager_not_configured"), "wallet_reference": .reference(walletId), ] ) return } - defer { finishCoreDiagnosticsNativeOp() } let managerHandle = handle let managedWallet = wallets[walletId] - let cancellation = coreDiagnosticsCancellation await withCheckedContinuation { continuation in Self.coreDiagnosticsQueue.async { Self.emitCoreMemoryDiagnostics( managerHandle: managerHandle, managedWallet: managedWallet, + walletId: walletId, database: database, checkpoint: checkpoint, cancellation: cancellation @@ -1323,7 +1394,8 @@ extension PlatformWalletManager { private nonisolated static func emitCoreMemoryDiagnostics( managerHandle: Handle, managedWallet: ManagedPlatformWallet?, - database: CoreWalletDatabaseDiagnosticSnapshot, + walletId: Data, + database: CoreWalletDatabaseDiagnosticSnapshot?, checkpoint: CoreWalletDiagnosticCheckpoint, cancellation: CoreDiagnosticsCancellation ) { @@ -1341,7 +1413,7 @@ extension PlatformWalletManager { "checkpoint": .publicText(checkpoint.rawValue), "reason": .publicText("shutdown_requested"), "skipped_from_stage": .publicText(stage), - "wallet_reference": .reference(database.walletId), + "wallet_reference": .reference(walletId), ] ) return true @@ -1353,13 +1425,14 @@ extension PlatformWalletManager { if shutdownBegan(before: "asset_locks") { return } compareAssetLocks( database, + walletId: walletId, managedWallet: managedWallet, checkpoint: checkpoint ) if shutdownBegan(before: "account_balances") { return } let balanceQuery = readAccountBalances( handle: managerHandle, - walletId: database.walletId + walletId: walletId ) guard case .success(let balances) = balanceQuery else { SDKLogger.event( @@ -1369,7 +1442,7 @@ extension PlatformWalletManager { fields: [ "checkpoint": .publicText(checkpoint.rawValue), "reason": .publicText("account_balance_query_failed"), - "wallet_reference": .reference(database.walletId), + "wallet_reference": .reference(walletId), ] ) return @@ -1387,7 +1460,7 @@ extension PlatformWalletManager { if shutdownBegan(before: "account_utxos") { return } let query = diagnosticAccountUtxos( managerHandle: managerHandle, - walletId: database.walletId, + walletId: walletId, balance: balance ) guard case .success(let utxos) = query else { @@ -1401,7 +1474,7 @@ extension PlatformWalletManager { "account_type": .unsignedInteger(UInt64(key.typeTag)), "checkpoint": .publicText(checkpoint.rawValue), "query_available": .boolean(false), - "wallet_reference": .reference(database.walletId), + "wallet_reference": .reference(walletId), ] ) continue @@ -1435,13 +1508,14 @@ extension PlatformWalletManager { "utxo_value_duffs": .unsignedInteger( diagnosticSaturatingSum(utxos.map(\.amount)) ), - "wallet_reference": .reference(database.walletId), + "wallet_reference": .reference(walletId), ] ) memoryTxos.append(contentsOf: utxos) } compareDatabase( database, + walletId: walletId, memoryTxos: memoryTxos, memoryAccounts: Set(balances.map(Self.diagnosticAccountKey)), unavailableAccounts: unavailableAccounts, @@ -1452,12 +1526,34 @@ extension PlatformWalletManager { /// Logs the deterministic DB↔Rust UTXO diff, excluding accounts whose Rust /// UTXO query failed instead of falsely reporting all their rows DB-only. private nonisolated static func compareDatabase( - _ database: CoreWalletDatabaseDiagnosticSnapshot, + _ database: CoreWalletDatabaseDiagnosticSnapshot?, + walletId: Data, memoryTxos: [CoreWalletDatabaseDiagnosticSnapshot.Txo], memoryAccounts: Set, unavailableAccounts: Set, checkpoint: CoreWalletDiagnosticCheckpoint ) { + guard let database else { + // No database side to diff against: say so, with the memory side's + // size, rather than emit nothing — an absent summary reads like a + // truncated log, and this is the case where Rust may still hold + // the funds the database lost. + SDKLogger.event( + "core_db_memory_diff_summary", + category: .persistence, + severity: .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "database_snapshot_available": .boolean(false), + "diff_incomplete": .boolean(true), + "memory_account_count": .integer(Int64(memoryAccounts.count)), + "memory_txo_count": .integer(Int64(memoryTxos.count)), + "unavailable_account_count": .integer(Int64(unavailableAccounts.count)), + "wallet_reference": .reference(walletId), + ] + ) + return + } let excludedDatabaseTxos = database.unspentTxos.filter { row in row.account.map(unavailableAccounts.contains) ?? false } @@ -1480,6 +1576,7 @@ extension PlatformWalletManager { fields: [ "checkpoint": .publicText(checkpoint.rawValue), "common_count": .integer(Int64(result.commonCount)), + "database_snapshot_available": .boolean(true), "database_account_only_count": .integer( Int64(result.databaseAccountOnlyCount) ), @@ -1491,12 +1588,12 @@ extension PlatformWalletManager { "memory_account_only_count": .integer(Int64(result.memoryAccountOnlyCount)), "truncated_count": .integer(Int64(result.truncatedCount)), "unavailable_account_count": .integer(Int64(unavailableAccounts.count)), - "wallet_reference": .reference(database.walletId), + "wallet_reference": .reference(walletId), ] ) for detail in result.emittedDetails { logDiffItem( - database.walletId, + walletId, checkpoint, detail.row, detail.outpoint, @@ -1530,7 +1627,8 @@ extension PlatformWalletManager { /// Captures the managed wallet's tracked locks and compares them with the /// queue-safe SwiftData snapshot. Raw outpoints are only reference-hashed. private nonisolated static func compareAssetLocks( - _ database: CoreWalletDatabaseDiagnosticSnapshot, + _ database: CoreWalletDatabaseDiagnosticSnapshot?, + walletId: Data, managedWallet: ManagedPlatformWallet?, checkpoint: CoreWalletDiagnosticCheckpoint ) { @@ -1548,7 +1646,7 @@ extension PlatformWalletManager { fields: [ "checkpoint": .publicText(checkpoint.rawValue), "query_available": .boolean(false), - "wallet_reference": .reference(database.walletId), + "wallet_reference": .reference(walletId), ] ) // Mirror the database-unavailable path below: an analyst greps for @@ -1561,12 +1659,13 @@ extension PlatformWalletManager { severity: .warning, fields: [ "checkpoint": .publicText(checkpoint.rawValue), - "database_query_available": .boolean(database.assetLocksAvailable), + "database_query_available": .boolean(database?.assetLocksAvailable ?? false), + "database_snapshot_available": .boolean(database != nil), "diff_incomplete": .boolean(true), "memory_query_available": .boolean(false), "mismatch_count": .integer(0), "truncated_count": .integer(0), - "wallet_reference": .reference(database.walletId), + "wallet_reference": .reference(walletId), ] ) return @@ -1585,7 +1684,7 @@ extension PlatformWalletManager { "shielded_funding_count": .integer(Int64(memory.filter { $0.fundingType == .assetLockShieldedAddressTopUp }.count)), - "wallet_reference": .reference(database.walletId), + "wallet_reference": .reference(walletId), ] ) @@ -1606,7 +1705,7 @@ extension PlatformWalletManager { "funding_type": .unsignedInteger(UInt64(first.fundingType.rawValue)), "proof_present_count": .integer(Int64(group.filter(\.hasProof).count)), "status": .unsignedInteger(UInt64(first.status.rawValue)), - "wallet_reference": .reference(database.walletId), + "wallet_reference": .reference(walletId), ] ) } @@ -1622,7 +1721,7 @@ extension PlatformWalletManager { hasProof: row.hasProof ) } - guard database.assetLocksAvailable else { + guard let database, database.assetLocksAvailable else { SDKLogger.event( "asset_lock_db_memory_diff_summary", category: .persistence, @@ -1630,11 +1729,12 @@ extension PlatformWalletManager { fields: [ "checkpoint": .publicText(checkpoint.rawValue), "database_query_available": .boolean(false), + "database_snapshot_available": .boolean(database != nil), "diff_incomplete": .boolean(true), "memory_query_available": .boolean(true), "mismatch_count": .integer(0), "truncated_count": .integer(0), - "wallet_reference": .reference(database.walletId), + "wallet_reference": .reference(walletId), ] ) return @@ -1650,11 +1750,12 @@ extension PlatformWalletManager { fields: [ "checkpoint": .publicText(checkpoint.rawValue), "database_query_available": .boolean(true), + "database_snapshot_available": .boolean(true), "diff_incomplete": .boolean(false), "memory_query_available": .boolean(true), "mismatch_count": .integer(Int64(result.details.count)), "truncated_count": .integer(Int64(result.truncatedCount)), - "wallet_reference": .reference(database.walletId), + "wallet_reference": .reference(walletId), ] ) for detail in result.emittedDetails { @@ -1666,7 +1767,7 @@ extension PlatformWalletManager { "checkpoint": .publicText(checkpoint.rawValue), "outpoint_reference": .referenceString(detail.outpointDisplay), "reason": .publicText(detail.reason), - "wallet_reference": .reference(database.walletId), + "wallet_reference": .reference(walletId), ] ) } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swift index 5c797a8cbb3..de3c5fc0c63 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swift @@ -91,28 +91,6 @@ public struct PlatformSpvSyncProgress: Sendable, Equatable { } } -enum CoreRescanDiagnosticResult: String, Sendable, Equatable { - /// The request lowered the checkpoint, so the filter sync will rescan. - case armed - /// The request was at or above the checkpoint: stored, but no rescan (see - /// ``PlatformWalletManager/spvRescanFilters(walletId:fromHeight:)``). - case noOp = "no_op" - /// The checkpoint could not be read, so nothing about a rewind is known. - case unknownPreviousHeight = "unknown_previous_height" -} - -/// Classifies only what can be proven from the checkpoint visible before the -/// accepted FFI call. A missing checkpoint is not evidence of a rewind: without -/// it, an analyst must not be able to read the log as ruling one out, which is -/// what any positive label would invite. -func coreRescanDiagnosticResult( - previousSyncedHeight: UInt32?, - requestedStartHeight: UInt32 -) -> CoreRescanDiagnosticResult { - guard let previousSyncedHeight else { return .unknownPreviousHeight } - return requestedStartHeight < previousSyncedHeight ? .armed : .noOp -} - /// Node type of a connected SPV peer, classified against the masternode /// list. Mirrors Rust's `SpvPeerNodeType` / the `SPV_PEER_NODE_TYPE_*` /// FFI constants. @@ -334,11 +312,11 @@ extension PlatformWalletManager { /// - fromHeight: the core block height to rewind the filter scan to. public func spvRescanFilters(walletId: Data, fromHeight: UInt32) throws { guard walletId.count == 32 else { - // Every other way this method fails leaves a `core_rescan_armed` + // Every other way this method fails leaves a `core_rescan_requested` // line; a rejected request must too, or the export reads as if // no rescan was ever asked for. SDKLogger.event( - "core_rescan_armed", + "core_rescan_requested", category: .persistence, severity: .error, fields: [ @@ -351,7 +329,12 @@ extension PlatformWalletManager { "walletId must be exactly 32 bytes" ) } - let previousHeight = coreWalletState(for: walletId)?.syncedHeight + // The event records the request and whether the FFI accepted it — + // nothing about whether a rewind actually happened. Classifying that + // would need the filter-scan checkpoint, which is not the core + // wallet's synced height and is not readable without a blocking + // Rust-lock FFI call on the main actor; a label built on either + // would let an analyst rule a rewind in or out on false grounds. do { try walletId.withUnsafeBytes { widRaw in guard let widPtr = widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self) @@ -360,37 +343,25 @@ extension PlatformWalletManager { } try platform_wallet_manager_spv_rescan_filters(handle, widPtr, fromHeight).check() } - let diagnosticResult = coreRescanDiagnosticResult( - previousSyncedHeight: previousHeight, - requestedStartHeight: fromHeight - ) - var fields: [String: SDKLogValue] = [ - "from_height": .unsignedInteger(UInt64(fromHeight)), - "result": .publicText(diagnosticResult.rawValue), - "wallet_reference": .reference(walletId), - ] - if let previousHeight { - fields["previous_synced_height"] = .unsignedInteger(UInt64(previousHeight)) - } SDKLogger.event( - "core_rescan_armed", + "core_rescan_requested", category: .persistence, - fields: fields + fields: [ + "from_height": .unsignedInteger(UInt64(fromHeight)), + "result": .publicText("accepted"), + "wallet_reference": .reference(walletId), + ] ) } catch { - var fields: [String: SDKLogValue] = [ - "from_height": .unsignedInteger(UInt64(fromHeight)), - "result": .publicText("failed"), - "wallet_reference": .reference(walletId), - ] - if let previousHeight { - fields["previous_synced_height"] = .unsignedInteger(UInt64(previousHeight)) - } SDKLogger.event( - "core_rescan_armed", + "core_rescan_requested", category: .persistence, severity: .error, - fields: fields + fields: [ + "from_height": .unsignedInteger(UInt64(fromHeight)), + "result": .publicText("failed"), + "wallet_reference": .reference(walletId), + ] ) throw error } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 1804a148af7..b265b0a05bb 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -117,10 +117,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// `serialQueue`: every public entry point wraps its body in /// `onQueue { … }`, and internal helpers (`upsertTransaction`, /// `markUtxoSpent`, …) assume they are already on the queue. - /// Internal only so the read-only diagnostics extension can take its - /// snapshot on the same serialized context as the persistence callbacks. - /// Production persistence code must continue to enter through `onQueue`. - let backgroundContext: ModelContext + private let backgroundContext: ModelContext /// Taken instead of `backgroundContext.fetch` by the reads whose /// failure must reject the round (see `ModelFetching`). @@ -138,9 +135,12 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// entry points — both the FFI callback shims and the /// app-facing accessors — funnel through `onQueue` so the /// context is only ever touched on this queue. - /// Internal only so diagnostics can enqueue an asynchronous, read-only - /// snapshot without blocking the main actor. All mutations remain in this - /// file's persistence callbacks. + /// Internal only so the read-only diagnostics extension can enqueue its + /// export pass here without blocking the main actor. That pass runs on a + /// scratch `ModelContext` of its own — it never touches + /// `backgroundContext`, so it sees only committed state — and needs this + /// queue solely so no save can land while it reads. All mutations remain + /// in this file's persistence callbacks. let serialQueue = DispatchQueue( label: "org.dash.platform-wallet.persistence", qos: .userInitiated @@ -233,10 +233,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// /// The pool goes inside the `sync` so it wraps exactly one unit of work /// and is drained before the Rust caller is resumed. - /// Internal only for the read-only diagnostics extension. Keeping the - /// diagnostic reads on this queue gives each exported snapshot a coherent - /// view and prevents it racing an in-flight Rust changeset save. - func onQueue(_ body: () throws -> T) rethrows -> T { + private func onQueue(_ body: () throws -> T) rethrows -> T { try serialQueue.sync { try autoreleasepool { try body() } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticAnalyzerTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticAnalyzerTests.swift index 48b4ffe9061..97ef907fae7 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticAnalyzerTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticAnalyzerTests.swift @@ -362,41 +362,6 @@ final class CoreWalletDiagnosticAnalyzerTests: XCTestCase { ) } - func testRescanDiagnosticResultOnlyReportsArmedForARealRewind() { - XCTAssertEqual( - coreRescanDiagnosticResult( - previousSyncedHeight: 2_500_000, - requestedStartHeight: 2_484_000 - ), - .armed - ) - XCTAssertEqual( - coreRescanDiagnosticResult( - previousSyncedHeight: 2_484_000, - requestedStartHeight: 2_484_000 - ), - .noOp - ) - // Above the checkpoint is stored but arms nothing, exactly like the - // equal case — see `spvRescanFilters`. - XCTAssertEqual( - coreRescanDiagnosticResult( - previousSyncedHeight: 2_480_000, - requestedStartHeight: 2_484_000 - ), - .noOp - ) - // No checkpoint was readable, so the log must not let an analyst rule - // a rewind in or out. - XCTAssertEqual( - coreRescanDiagnosticResult( - previousSyncedHeight: nil, - requestedStartHeight: 2_484_000 - ), - .unknownPreviousHeight - ) - } - private func fingerprintMaterial(_ txo: Txo) -> Data { diagnosticTxoFingerprint( outpoint: txo.outpoint, diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SDKLoggerPreInstallBufferTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SDKLoggerPreInstallBufferTests.swift index 7076adef985..b2af9381abc 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SDKLoggerPreInstallBufferTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SDKLoggerPreInstallBufferTests.swift @@ -63,7 +63,9 @@ final class SDKLoggerPreInstallBufferTests: XCTestCase { XCTAssertEqual(try writtenLines(shown), ["debug", "info"]) } - func testBufferDropsTheOldestLineAboveTheLimitAndReportsIt() throws { + /// Head-not-tail: the store-open line is the first in, and it is the one + /// the buffer exists to carry, so overflow discards the newest arrival. + func testBufferKeepsTheHeadAndDropsTheNewestAboveTheLimit() throws { let state = SDKLoggerState() let limit = SDKLoggerState.pendingLineLimit // limit + 1 lines: exactly one over. @@ -79,8 +81,8 @@ final class SDKLoggerPreInstallBufferTests: XCTestCase { let lines = try writtenLines(state) XCTAssertEqual(lines.count, limit) - XCTAssertEqual(lines.first, "line-1", "the oldest line is the one dropped") - XCTAssertEqual(lines.last, "line-\(limit)") + XCTAssertEqual(lines.first, "line-0", "the first line in survives") + XCTAssertEqual(lines.last, "line-\(limit - 1)", "the newest arrival is the one dropped") } func testBufferIsClearedByInstallSoASecondInstallReplaysNothing() throws { From d86a76325e1abcabff41048c3a379e33c0625d02 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 8 Sep 2026 10:32:20 +0200 Subject: [PATCH 12/17] fix(swift-sdk): never claim drift from a comparison that did not happen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `storeSchemaVerdict` returned `driftedRegisteredVersion` — the one verdict that authorizes inferred lightweight migration — for a store whose metadata carried no entity hashes at all: nothing disagreed because nothing was compared, and the store would have been opened by inference and trimmed to the current schema. Drift now requires at least one entity actually compared and at least one actually disagreeing. Stores that cannot be placed no longer borrow the newer-build error either. `no_version_identifier` is an old or truncated store as much as a new one, and `storeFromNewerBuild` tells the user to update the app or reset the wallet — destructive advice on a store that is fine. A new `.unplaceable` verdict rethrows SwiftData's own error instead, with the verdict in the log. `shutdown()` raised the diagnostics cancellation after the handle guard, so a pass running for a never-configured manager — the branch that deliberately runs its database half without a handle — could not be told to stop, and held the persistence queue across teardown. Cancel before the guard. The #4438 audit reported a clean, complete result when the persisted BIP44 address pool was empty: every output fell through as unattributed and nothing reached the missing-TXO check, on exactly the wallet whose address rows went missing. An empty pool is now an incompleteness like an undecodable transaction. Deliberately not `unattributed_output_count > 0`: a CoinJoin transaction pays its peers, so every healthy audit has some. Also: an autorelease pool per account in the memory half, matching the database half — the whole loop is one GCD work item, so the peak was the sum of every account rather than the largest; and the restore snapshot no longer classifies every row on the errored path, where it faulted a relationship per row, at launch, under the queue, to describe a load being discarded. Co-Authored-By: Claude Opus 5 --- .../Persistence/DashModelContainer.swift | 42 ++++- .../CoreWalletDiagnosticAnalyzers.swift | 11 +- .../PlatformWalletManager.swift | 8 + ...PlatformWalletManagerCoreDiagnostics.swift | 167 +++++++++++------- .../Dev1StoreUpgradeTests.swift | 16 +- 5 files changed, 176 insertions(+), 68 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 87ce376b6be..b127556096f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -79,6 +79,14 @@ public enum DashModelContainer { /// wrote, so it must not run; the pre-fallback crash was the safe /// outcome here. case newerThanRegistered(reason: String) + /// The metadata reads but does not place the store against any + /// registered version: no declared version identifier, or nothing to + /// compare it by. Inferred migration must not answer this either — an + /// unplaced store opened by inference is trimmed to the current schema + /// exactly like a downgrade — but it is NOT evidence of a newer build, + /// so the host must not be told to update or reset. SwiftData's own + /// error is passed through instead. + case unplaceable(reason: String) var logLabel: String { switch self { @@ -86,6 +94,7 @@ public enum DashModelContainer { case .matchesRegisteredVersion: return "matches_registered_version" case .driftedRegisteredVersion: return "drifted_registered_version" case .newerThanRegistered(let reason): return "newer_than_registered:\(reason)" + case .unplaceable(let reason): return "unplaceable:\(reason)" } } } @@ -168,8 +177,19 @@ public enum DashModelContainer { if let unknown = storeVersionIdentifiers.first(where: { !registeredIdentifiers.contains($0) }) { return .newerThanRegistered(reason: "unregistered_version_identifier=\(unknown)") } + // No identifier at all places the store nowhere. Older stores and + // stores with truncated metadata land here too, so this is not a + // newer build and must not be reported to the user as one. guard !storeVersionIdentifiers.isEmpty else { - return .newerThanRegistered(reason: "no_version_identifier") + return .unplaceable(reason: "no_version_identifier") + } + // Drift is a statement about hashes that disagree. With no hashes to + // read there is nothing to disagree, and every check below would pass + // vacuously — `disagreeing` empty, so `unknownShapes` empty, so + // `driftedRegisteredVersion` from a comparison that never happened, + // authorizing inferred migration over a store nothing is known about. + guard !storeEntityHashes.isEmpty else { + return .unplaceable(reason: "no_entity_hashes") } // An entity the current schema does not have can only have been @@ -195,11 +215,20 @@ public enum DashModelContainer { let unknownShapes = Set(disagreeing.compactMap { name, hash in knownDriftedEntityHashes[name] == hash ? nil : name }) - if unknownShapes.isEmpty { + // `!disagreeing.isEmpty` is the load-bearing half: drift is what + // the fallback answers, and a store that agrees on every hash it + // carries and still is not compatible differs by something these + // hashes do not describe — an entity the store lacks entirely, + // say. Whatever that is, it is not the drift the pinned hashes + // authorize, so it does not get inferred migration. + if !disagreeing.isEmpty, unknownShapes.isEmpty { return .driftedRegisteredVersion } unexpectedDrift.formUnion(unknownShapes) } + guard !unexpectedDrift.isEmpty else { + return .unplaceable(reason: "no_entity_disagreement") + } return .newerThanRegistered( reason: "unexpected_entity_drift=\(unexpectedDrift.sorted().joined(separator: "|"))" ) @@ -475,9 +504,12 @@ public enum DashModelContainer { guard case .driftedRegisteredVersion = verdict else { report(succeeded: false, migrationPath: .staged, error: error, storeVerdict: verdict) // A newer build's store is the one refusal the host can act - // on (tell the user to update or reset), so it gets a typed - // error; everything else is SwiftData's own failure, passed - // through untouched. + // on, so it gets a typed error — and only it, because that + // error's text tells the user to update the app or reset the + // wallet, and resetting is destructive on a store that is + // merely unplaceable. `.unplaceable` and `.unreadable` are + // SwiftData's own failure, passed through untouched with the + // verdict in the log. if case .newerThanRegistered(let reason) = verdict { throw DashModelContainerError.storeFromNewerBuild(reason: reason) } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWalletDiagnosticAnalyzers.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWalletDiagnosticAnalyzers.swift index 4be235ecaee..849bd94cd71 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWalletDiagnosticAnalyzers.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWalletDiagnosticAnalyzers.swift @@ -240,10 +240,17 @@ enum CoreWalletDiagnosticAnalyzer { /// the launch restore path while the persistence queue is held, so a large /// CoinJoin wallet must not pay for a dozen full-length `filter`/`map` /// allocations, and nothing here retains a per-row array. + /// - Parameter candidateCountOverride: reported instead of the number of + /// candidates walked. For the errored path, where classifying each row + /// would fault a relationship per row to describe a load that is being + /// discarded: the caller passes the row count it already has and an + /// empty sequence, so `candidate_count` stays truthful and every other + /// counter is honestly zero. static func summarizeRestoreBuffer( candidates: S, emittedCount: Int, - errored: Bool + errored: Bool, + candidateCountOverride: Int? = nil ) -> RestoreBufferSummary where S.Element == RestoreCandidate { // Rust is handed the first `emittedCount` rows that passed validation, // in order; an errored build deallocated the whole buffer, so none of @@ -318,7 +325,7 @@ enum CoreWalletDiagnosticAnalyzer { } return RestoreBufferSummary( - candidateCount: candidateCount, + candidateCount: candidateCountOverride ?? candidateCount, candidateValueDuffs: candidateValue, candidateBip44Count: candidateBip44Count, candidateBip44ValueDuffs: candidateBip44Value, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index d7931ffc65a..2546c3a5587 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -673,6 +673,14 @@ public class PlatformWalletManager: ObservableObject { if let task = shutdownTask { return await task.value } + // Before the handle guard, not after it: a diagnostic pass can be + // running on the persistence queue for a manager that was never + // configured (`emitCoreWalletDiagnostics` runs its database half + // with no handle), and the early return below would leave it with + // no way to be told to stop — holding the queue, and every Rust + // persister callback entering through it, across teardown. The + // flag is one-way and costs nothing on the no-op path. + coreDiagnosticsCancellation.cancel() 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 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift index ef0fc8477df..a2628830251 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift @@ -720,19 +720,41 @@ extension PlatformWalletPersistenceHandler { rejectionReason: rejection ) } - // Lazily, and with the built rows first: the summary's emission window - // is positional, so the rejected rows must not shift it. Nothing here - // materializes a per-row array — this runs while the launch restore - // holds the persistence queue. - let candidates = [rows, accountLessRows].lazy.flatMap { $0 }.map(candidate) // A validation error deallocates the compact buffer and aborts the // whole callback, so zero rows were actually handed to Rust even if // some valid rows preceded the corrupt one. - let summary = CoreWalletDiagnosticAnalyzer.summarizeRestoreBuffer( - candidates: candidates, - emittedCount: emittedCount, - errored: errored - ) + // + // It also means classifying the rows would cost more than it is worth: + // `buildUtxoRestoreBuffer` can bail at row 0 having faulted nothing, + // and `candidate` touches `account` and `txid` — to-one relationships + // — on every row, so the walk would issue a fault per row, at launch, + // with the persistence queue held, to describe a load that is about to + // be discarded. The row that failed is already named with its reason + // by `persistence_wallet_load_validation_failed`; here the count is + // what is left to say, and the positional emission window the walk + // exists for is moot at zero emitted. + let summary: CoreWalletDiagnosticAnalyzer.RestoreBufferSummary + if errored { + summary = CoreWalletDiagnosticAnalyzer.summarizeRestoreBuffer( + candidates: EmptyCollection< + CoreWalletDiagnosticAnalyzer.RestoreCandidate + >(), + emittedCount: 0, + errored: true, + candidateCountOverride: rows.count + accountLessRows.count + ) + } else { + // Lazily, and with the built rows first: the summary's emission + // window is positional, so the rejected rows must not shift it. + // Nothing here materializes a per-row array — this runs while the + // launch restore holds the persistence queue, and every row it + // touches was already faulted by the build it is reconciling. + summary = CoreWalletDiagnosticAnalyzer.summarizeRestoreBuffer( + candidates: [rows, accountLessRows].lazy.flatMap { $0 }.map(candidate), + emittedCount: emittedCount, + errored: false + ) + } let hasRejectedRows = summary.missingAccountCount > 0 || summary.invalidTxidCount > 0 || summary.invalidAccountTypeCount > 0 @@ -1057,15 +1079,29 @@ extension PlatformWalletPersistenceHandler { let missingValue = diagnosticSaturatingSum(anomalies.compactMap { $0.reason == "missing_txo" ? $0.amount : nil }) + // With no persisted BIP44 addresses nothing can be attributed to this + // wallet: every decoded output falls into `unattributedOutputCount`, + // no output reaches the `missing_txo` check, and the summary would + // otherwise read as a clean, complete audit — on a wallet whose + // address rows are exactly what went missing. The pool being empty is + // an incompleteness of the same kind as an undecodable transaction. + // + // `unattributedOutputCount > 0` is deliberately NOT part of this: a + // CoinJoin-spending transaction pays its peers, and their outputs are + // unattributable by construction, so every healthy audit has some. + // A partially lost pool is not distinguishable from a small one here; + // `bip44_address_pool_size` sits beside this flag for that reading. + let addressPoolEmpty = bip44Addresses.isEmpty + let auditIncomplete = decodeFailureCount > 0 + || transactionBytesMissingCount > 0 + || addressPoolEmpty SDKLogger.event( "core_owned_output_audit_summary", category: .persistence, - severity: anomalies.isEmpty && decodeFailureCount == 0 - && transactionBytesMissingCount == 0 ? .info : .warning, + severity: anomalies.isEmpty && !auditIncomplete ? .info : .warning, fields: [ - "audit_incomplete": .boolean( - decodeFailureCount > 0 || transactionBytesMissingCount > 0 - ), + "audit_incomplete": .boolean(auditIncomplete), + "bip44_address_pool_empty": .boolean(addressPoolEmpty), "bip44_address_pool_size": .integer(Int64(bip44Addresses.count)), "candidate_transaction_count": .integer(Int64(candidateCount)), "checkpoint": .publicText(checkpoint.rawValue), @@ -1456,62 +1492,73 @@ extension PlatformWalletManager { ) } for balance in sortedBalances { - let key = Self.diagnosticAccountKey(balance) if shutdownBegan(before: "account_utxos") { return } - let query = diagnosticAccountUtxos( - managerHandle: managerHandle, - walletId: walletId, - balance: balance - ) - guard case .success(let utxos) = query else { - unavailableAccounts.insert(key) + // One pool per account, matching `emitCoreWalletDatabaseDiagnostics`. + // libdispatch drains its own pool once per work item, and this + // whole loop is one work item: without this, every account's + // per-UTXO txid and scriptPubKey copies, its fingerprint material + // and the formatter each log event allocates all stay resident + // until the export ends, so the peak is the sum of every account + // rather than the largest one. `memoryTxos` is returned out of the + // pool on purpose — `compareDatabase` needs the whole set. + let accountTxos: [CoreWalletDatabaseDiagnosticSnapshot.Txo]? = autoreleasepool { + let key = Self.diagnosticAccountKey(balance) + let query = diagnosticAccountUtxos( + managerHandle: managerHandle, + walletId: walletId, + balance: balance + ) + guard case .success(let utxos) = query else { + unavailableAccounts.insert(key) + SDKLogger.event( + "core_memory_account_snapshot", + category: .persistence, + severity: .warning, + fields: [ + "account_reference": .reference(key.referenceMaterial), + "account_type": .unsignedInteger(UInt64(key.typeTag)), + "checkpoint": .publicText(checkpoint.rawValue), + "query_available": .boolean(false), + "wallet_reference": .reference(walletId), + ] + ) + return nil + } + let materials = utxos.map { + diagnosticTxoFingerprint( + outpoint: $0.outpoint, + amount: $0.amount, + height: $0.height, + scriptPubKey: $0.scriptPubKey, + isLocked: $0.isLocked, + account: key + ) + } SDKLogger.event( "core_memory_account_snapshot", category: .persistence, - severity: .warning, fields: [ + "account_index": .unsignedInteger(UInt64(balance.index)), "account_reference": .reference(key.referenceMaterial), - "account_type": .unsignedInteger(UInt64(key.typeTag)), + "account_type": .unsignedInteger(UInt64(balance.typeTag)), "checkpoint": .publicText(checkpoint.rawValue), - "query_available": .boolean(false), + "confirmed_duffs": .unsignedInteger(balance.confirmed), + "immature_duffs": .unsignedInteger(balance.immature), + "locked_duffs": .unsignedInteger(balance.locked), + "query_available": .boolean(true), + "standard_tag": .unsignedInteger(UInt64(balance.standardTag)), + "unconfirmed_duffs": .unsignedInteger(balance.unconfirmed), + "utxo_count": .integer(Int64(utxos.count)), + "utxo_fingerprint": .reference(diagnosticFingerprint(materials)), + "utxo_value_duffs": .unsignedInteger( + diagnosticSaturatingSum(utxos.map(\.amount)) + ), "wallet_reference": .reference(walletId), ] ) - continue + return utxos } - let materials = utxos.map { - diagnosticTxoFingerprint( - outpoint: $0.outpoint, - amount: $0.amount, - height: $0.height, - scriptPubKey: $0.scriptPubKey, - isLocked: $0.isLocked, - account: key - ) - } - SDKLogger.event( - "core_memory_account_snapshot", - category: .persistence, - fields: [ - "account_index": .unsignedInteger(UInt64(balance.index)), - "account_reference": .reference(key.referenceMaterial), - "account_type": .unsignedInteger(UInt64(balance.typeTag)), - "checkpoint": .publicText(checkpoint.rawValue), - "confirmed_duffs": .unsignedInteger(balance.confirmed), - "immature_duffs": .unsignedInteger(balance.immature), - "locked_duffs": .unsignedInteger(balance.locked), - "query_available": .boolean(true), - "standard_tag": .unsignedInteger(UInt64(balance.standardTag)), - "unconfirmed_duffs": .unsignedInteger(balance.unconfirmed), - "utxo_count": .integer(Int64(utxos.count)), - "utxo_fingerprint": .reference(diagnosticFingerprint(materials)), - "utxo_value_duffs": .unsignedInteger( - diagnosticSaturatingSum(utxos.map(\.amount)) - ), - "wallet_reference": .reference(walletId), - ] - ) - memoryTxos.append(contentsOf: utxos) + if let accountTxos { memoryTxos.append(contentsOf: accountTxos) } } compareDatabase( database, diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift index c01eb67a299..966be86c8a4 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift @@ -357,9 +357,23 @@ final class Dev1StoreUpgradeTests: XCTestCase { verdict(["PersistentWallet": a], identifiers: ["9.0.0"]), .newerThanRegistered(reason: "unregistered_version_identifier=9.0.0") ) + // Unplaceable, NOT a newer build: `open` must rethrow SwiftData's own + // error for these rather than tell the user their wallet came from a + // newer app and offer a reset. XCTAssertEqual( verdict(["PersistentWallet": a], identifiers: []), - .newerThanRegistered(reason: "no_version_identifier") + .unplaceable(reason: "no_version_identifier") + ) + XCTAssertEqual( + verdict([:]), + .unplaceable(reason: "no_entity_hashes"), + "no hashes means nothing was compared; drift may not be claimed" + ) + XCTAssertEqual( + verdict(["PersistentWallet": a]), + .unplaceable(reason: "no_entity_disagreement"), + "every hash the store carries agrees and it still is not compatible — " + + "it differs by something these hashes do not describe, not by the pinned drift" ) XCTAssertEqual( verdict(["PersistentWallet": a, "FutureOnlyModel": a]), From abde130845fbb28d2fb31ea544f6474311c7b8f9 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 8 Sep 2026 11:43:16 +0200 Subject: [PATCH 13/17] fix(swift-sdk): only claim "newer build" where the evidence has a direction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hash disagreement is symmetric — it says the store's shape of an entity is not the live model's, not which came first. V1/V2/V3 are unfrozen, so adding one attribute to any live model makes every EXISTING store disagree on that entity: an older store, reported as a newer one, with "reset the wallet" as the offered remedy. An unregistered version identifier is ambiguous the same way (pre-V1, or a version since dropped from the plan). Both become `.unplaceable`, which rethrows SwiftData's own error. The one asymmetric fact left — the store carries an entity this schema does not have — keeps `newerThanRegistered`, and with it the only honest "update the app". Last round's cancellation fix overshot: latching above the handle guard also latched on the guard's deliberately uncached no-op return, so a manager built and shut down before `configure()` could never produce a support export again. The latch is now as conditional as the state it accompanies — a live handle, or a pass actually in flight, counted for both halves. The flag was also polled at only three points, so `shutdown()`'s drain could wait for the whole-wallet fingerprint, the per-account snapshots and `logTxoAnomalies` — the stages that dominate the queue hold. Each is gated now, and the audit's check no longer hides inside `if let allTransactions`, where it was skipped exactly when the audit had already been declined. The #4438 audit knew only BIP44 addresses, so a mixed send's own CoinJoin change was booked as "unattributed" — documented as peers' outputs — and a CoinJoin-side output missing from PersistentTxo was invisible: a third route to the false all-clear. The pool now covers CoinJoin accounts, the account check compares against the account the pool named, and the missing counts are reported per side. Also: every early return out of the memory half now emits its `diff_incomplete=true` summary, since an absent summary reads as a truncated log; and the export entry point documents that its only caller is the host app, by design. Co-Authored-By: Claude Opus 5 --- .../Persistence/DashModelContainer.swift | 41 +++- .../PlatformWalletManager.swift | 44 ++++- ...PlatformWalletManagerCoreDiagnostics.swift | 180 ++++++++++++++---- .../Dev1StoreUpgradeTests.swift | 47 +++-- 4 files changed, 243 insertions(+), 69 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index b127556096f..502b69b51d8 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -73,11 +73,13 @@ public enum DashModelContainer { /// lists (every v4.2.0-dev.1 store, until the remaining V1/V2 shapes /// are frozen). Inferred migration may open it. case driftedRegisteredVersion - /// Written by a build this SDK does not know — a version identifier - /// it never registered, or an entity its schema lacks. Inferred - /// migration would open it and silently drop what the newer build - /// wrote, so it must not run; the pre-fallback crash was the safe - /// outcome here. + /// Positively written by a NEWER build: the store carries an entity + /// this schema does not have, which nothing older could have created. + /// Inferred migration would open it and silently drop that entity's + /// table, so it must not run; the pre-fallback crash was the safe + /// outcome here. This is the only verdict the host may turn into + /// "update the app", because it is the only one whose evidence has a + /// direction — see `unplaceable` for why a hash disagreement does not. case newerThanRegistered(reason: String) /// The metadata reads but does not place the store against any /// registered version: no declared version identifier, or nothing to @@ -170,12 +172,20 @@ public enum DashModelContainer { ) -> StoreSchemaVerdict { if matchesRegisteredVersion { return .matchesRegisteredVersion } + // Nothing to place the store against — every schema failed to build a + // model. Not a fact about the store at all. + guard !registered.isEmpty else { + return .unplaceable(reason: "no_registered_models") + } + // SwiftData writes each `VersionedSchema.versionIdentifier` into the - // store; one this plan never registered was written by a newer build, - // and a store carrying none cannot be placed at all. + // store. One this plan does not register places it nowhere, but says + // nothing about which side is older: a pre-V1 store and a version + // since dropped from `DashMigrationPlan.schemas` look exactly like a + // future one from here. let registeredIdentifiers = Set(registered.map(\.identifier)) if let unknown = storeVersionIdentifiers.first(where: { !registeredIdentifiers.contains($0) }) { - return .newerThanRegistered(reason: "unregistered_version_identifier=\(unknown)") + return .unplaceable(reason: "unregistered_version_identifier=\(unknown)") } // No identifier at all places the store nowhere. Older stores and // stores with truncated metadata land here too, so this is not a @@ -193,7 +203,9 @@ public enum DashModelContainer { } // An entity the current schema does not have can only have been - // written by a newer build; inferred migration would drop its table. + // written by a newer build — this is the one asymmetric fact + // available here, so it is the one verdict allowed to say "newer". + // Inferred migration would drop its table. let unknownEntities = Set(storeEntityHashes.keys).subtracting(currentEntities).sorted() if !unknownEntities.isEmpty { return .newerThanRegistered( @@ -229,7 +241,16 @@ public enum DashModelContainer { guard !unexpectedDrift.isEmpty else { return .unplaceable(reason: "no_entity_disagreement") } - return .newerThanRegistered( + // Deliberately NOT `newerThanRegistered`. A hash disagreement is + // symmetric: it says the store's shape of that entity is not the live + // model's, not which of the two came first. V1/V2/V3 are still + // unfrozen (only `PersistentAssetLock` is frozen), so adding one + // attribute to any live model makes every EXISTING store disagree on + // that entity — an older store, reported as a newer one, with a reset + // offered as the remedy. Direction needs evidence this comparison does + // not have; until the models are frozen, the honest verdict is that + // the store cannot be placed. + return .unplaceable( reason: "unexpected_entity_drift=\(unexpectedDrift.sorted().joined(separator: "|"))" ) } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 2546c3a5587..7a6e4610f98 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -473,11 +473,34 @@ public class PlatformWalletManager: ObservableObject { /// must not participate in `ensureSyncNativeOpAllowed`. private var activeCoreDiagnosticsNativeOpCount = 0 /// Set by `shutdown()` before it drains `activeCoreDiagnosticsNativeOpCount`. - /// A diagnostic pass checks it before every FFI read, so the drain waits - /// for at most the one read already in flight — never for the rest of an + /// A diagnostic pass checks it before every stage, so the drain waits for + /// at most the one stage already in flight — never for the rest of an /// export — and a support export can never outlive the process it is - /// diagnosing. Never reset: a manager is shut down once. + /// diagnosing. Never reset: a manager is shut down once, and a pass that + /// has been told to stop must not be able to un-tell itself. + /// + /// Which is why `shutdown()` raises it only when there is a real teardown + /// or a pass to stop — see the call site. A never-configured manager takes + /// an UNCACHED no-op return from `shutdown()` precisely so it can still be + /// configured afterwards, and a one-way latch set on that path would + /// silence diagnostics for the rest of a perfectly live manager's life. let coreDiagnosticsCancellation = CoreDiagnosticsCancellation() + + /// Diagnostic passes in flight, including the SwiftData half that runs + /// with no handle at all. `activeCoreDiagnosticsNativeOpCount` cannot + /// stand in for this: it counts only admitted FFI work, so it is zero in + /// exactly the unconfigured case whose database half still needs to be + /// told to stop. + private var activeCoreDiagnosticsPassCount = 0 + + func beginCoreDiagnosticsPass() { + activeCoreDiagnosticsPassCount += 1 + } + + func endCoreDiagnosticsPass() { + guard activeCoreDiagnosticsPassCount > 0 else { return } + activeCoreDiagnosticsPassCount -= 1 + } private var nativeOpDrainContinuations: [CheckedContinuation] = [] /// Admission + bookkeeping shared by the async native entrypoints: @@ -678,9 +701,18 @@ public class PlatformWalletManager: ObservableObject { // configured (`emitCoreWalletDiagnostics` runs its database half // with no handle), and the early return below would leave it with // no way to be told to stop — holding the queue, and every Rust - // persister callback entering through it, across teardown. The - // flag is one-way and costs nothing on the no-op path. - coreDiagnosticsCancellation.cancel() + // persister callback entering through it, across teardown. + // + // But only as conditionally as the shutdown state it accompanies: + // the guard's no-op return is deliberately UNCACHED so a manager + // built and shut down before `configure()` can still be configured + // later, and this latch is one-way, so raising it there would leave + // that live manager unable to produce a support export ever again. + // A real teardown, or a pass actually in flight, is the whole set + // of cases with something to cancel. + if handle != NULL_HANDLE || activeCoreDiagnosticsPassCount > 0 { + coreDiagnosticsCancellation.cancel() + } 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 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift index a2628830251..e0df0add3e0 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift @@ -406,6 +406,10 @@ extension PlatformWalletPersistenceHandler { let spent = walletTxos.filter(\.isSpent) let unspent = walletTxos.filter { !$0.isSpent } let locked = walletTxos.filter(\.isLocked) + // Every stage from here on is O(rows) or O(accounts × rows) on its + // own, so the drain's documented "at most one stage in flight" only + // holds if each is gated. The cost is one atomic read per stage. + if shutdownBegan(before: "wallet_fingerprint") { return nil } let txoFingerprint = diagnosticFingerprint(walletTxos.map { diagnosticTxoFingerprint( outpoint: $0.outpoint, @@ -531,6 +535,7 @@ extension PlatformWalletPersistenceHandler { tallies[id] = tally } + if shutdownBegan(before: "account_snapshots") { return nil } for account in sortedAccounts { let key = Self.diagnosticAccountKey(account)! let tally = tallies[ObjectIdentifier(account)] ?? AccountTally() @@ -573,6 +578,10 @@ extension PlatformWalletPersistenceHandler { ) } + // Faults `transaction`, `account.wallet` and `spendingTransaction` + // for every row it inspects — the single most expensive stage after + // the audit itself. + if shutdownBegan(before: "txo_anomalies") { return nil } Self.logTxoAnomalies( walletId: walletId, checkpoint: checkpoint, @@ -582,19 +591,25 @@ extension PlatformWalletPersistenceHandler { // be expensive. The exact #4438 audit is needed for the manually // exported artifact, not for restoring Rust, so keep startup's // persistence queue limited to lightweight summaries. - if let allTransactions, shutdownBegan(before: "owned_output_audit") { - SDKLogger.event( - "core_owned_output_audit_summary", - category: .persistence, - severity: .warning, - fields: [ - "audit_incomplete": .boolean(true), - "checkpoint": .publicText(checkpoint.rawValue), - "reason": .publicText("shutdown_requested"), - "transaction_row_count": .integer(Int64(allTransactions.count)), - "wallet_reference": .reference(walletId), - ] - ) + // Not nested inside `if let allTransactions`: the stages that + // follow run whether or not the audit was declined for table size, + // so gating the check on the audit's input skipped it exactly when + // the pass was already refusing to do the cheap thing. + if shutdownBegan(before: "owned_output_audit") { + if let allTransactions { + SDKLogger.event( + "core_owned_output_audit_summary", + category: .persistence, + severity: .warning, + fields: [ + "audit_incomplete": .boolean(true), + "checkpoint": .publicText(checkpoint.rawValue), + "reason": .publicText("shutdown_requested"), + "transaction_row_count": .integer(Int64(allTransactions.count)), + "wallet_reference": .reference(walletId), + ] + ) + } return nil } if let allTransactions { @@ -607,6 +622,7 @@ extension PlatformWalletPersistenceHandler { ) } + if shutdownBegan(before: "asset_lock_snapshot") { return nil } let assetLocks: [CoreWalletDatabaseDiagnosticSnapshot.AssetLock] let assetLocksAvailable: Bool do { @@ -631,6 +647,7 @@ extension PlatformWalletPersistenceHandler { ] ) } + if shutdownBegan(before: "shielded_snapshot") { return nil } do { try Self.logShieldedStoreSnapshot( context: context, @@ -931,15 +948,29 @@ extension PlatformWalletPersistenceHandler { // the same address would otherwise make `expectedAccount` — and so // `wrong_account` — depend on which faulted first; the same ordering // the account snapshots use makes the winner the same on every run. - var bip44Addresses: [String: PersistentAccount] = [:] + // + // Both kinds of account we can own an output on. A mixed send consumes + // a CoinJoin output and typically pays CoinJoin change back to the + // wallet: that output has a persisted address row, so booking it as + // unattributed both overstated "paid to someone else" and hid the case + // where it is the CoinJoin-side output that went missing from + // `PersistentTxo` — a third route to the false all-clear. + // + // BIP44 first, so a shared address keeps the BIP44 attribution this + // audit has always given it. + var ownedAddresses: [String: PersistentAccount] = [:] let bip44Accounts = wallet.accounts .filter { $0.accountType == 0 && $0.standardTag == 0 } .sorted(by: Self.accountOrder) - for account in bip44Accounts { - for coreAddress in account.coreAddresses where bip44Addresses[coreAddress.address] == nil { - bip44Addresses[coreAddress.address] = account + let coinJoinAccounts = wallet.accounts + .filter { $0.accountType == 1 } + .sorted(by: Self.accountOrder) + for account in bip44Accounts + coinJoinAccounts { + for coreAddress in account.coreAddresses where ownedAddresses[coreAddress.address] == nil { + ownedAddresses[coreAddress.address] = account } } + let bip44AddressCount = bip44Accounts.reduce(0) { $0 + $1.coreAddresses.count } let txoByOutpoint = Dictionary(grouping: allTxos, by: \.outpoint) var candidateCount = 0 @@ -947,11 +978,13 @@ extension PlatformWalletPersistenceHandler { var transactionBytesMissingCount = 0 var ownedOutputCount = 0 var ownedOutputValue: UInt64 = 0 + var ownedCoinJoinOutputCount = 0 + var ownedCoinJoinOutputValue: UInt64 = 0 var unattributedOutputCount = 0 var undecodableAddressOutputCount = 0 var validCount = 0 var anomalies: [(tx: PersistentTransaction, vout: UInt32, amount: UInt64, - outpoint: Data, reason: String)] = [] + outpoint: Data, reason: String, outputIsCoinJoin: Bool)] = [] guard let network = wallet.network else { SDKLogger.event( @@ -1008,22 +1041,29 @@ extension PlatformWalletPersistenceHandler { undecodableAddressOutputCount += 1 continue } - guard let expectedAccount = bip44Addresses[address] else { + guard let expectedAccount = ownedAddresses[address] else { // Either a genuine payment to someone else or one of our // own change addresses with no persisted row. The audit // cannot tell them apart, so it counts rather than clears. unattributedOutputCount += 1 continue } - ownedOutputCount += 1 - ownedOutputValue = diagnosticSaturatingAdd(ownedOutputValue, output.valueDuffs) + let outputIsCoinJoin = expectedAccount.accountType == 1 + if outputIsCoinJoin { + ownedCoinJoinOutputCount += 1 + ownedCoinJoinOutputValue = diagnosticSaturatingAdd( + ownedCoinJoinOutputValue, output.valueDuffs) + } else { + ownedOutputCount += 1 + ownedOutputValue = diagnosticSaturatingAdd(ownedOutputValue, output.valueDuffs) + } let vout = UInt32(index) let outpoint = PersistentTxo.makeOutpoint(txid: decoded.txid, vout: vout) guard let row = representativeTxo( rows: txoByOutpoint[outpoint], walletId: walletId ) else { - anomalies.append((transaction, vout, output.valueDuffs, outpoint, "missing_txo")) + anomalies.append((transaction, vout, output.valueDuffs, outpoint, "missing_txo", outputIsCoinJoin)) continue } // Same admission rule as `walletTxos` and the candidate set: @@ -1034,7 +1074,7 @@ extension PlatformWalletPersistenceHandler { let denormalizedNamesWallet = row.walletId == walletId let relationshipNamesWallet = rowRelationshipWallet == walletId guard denormalizedNamesWallet || relationshipNamesWallet else { - anomalies.append((transaction, vout, output.valueDuffs, outpoint, "wrong_wallet")) + anomalies.append((transaction, vout, output.valueDuffs, outpoint, "wrong_wallet", outputIsCoinJoin)) continue } guard relationshipNamesWallet else { @@ -1043,22 +1083,25 @@ extension PlatformWalletPersistenceHandler { // same two facts, so the analyst sees one story. let reason = rowRelationshipWallet == nil ? "relationship_missing" : "wallet_id_mismatch" - anomalies.append((transaction, vout, output.valueDuffs, outpoint, reason)) + anomalies.append((transaction, vout, output.valueDuffs, outpoint, reason, outputIsCoinJoin)) continue } + // Against the account the address pool named, not a hardcoded + // BIP44 shape: the same check now serves CoinJoin-owned + // outputs, whose account carries type 1. guard row.account === expectedAccount, - row.account?.accountType == 0, - row.account?.standardTag == 0 + row.account?.accountType == expectedAccount.accountType, + row.account?.standardTag == expectedAccount.standardTag else { - anomalies.append((transaction, vout, output.valueDuffs, outpoint, "wrong_account")) + anomalies.append((transaction, vout, output.valueDuffs, outpoint, "wrong_account", outputIsCoinJoin)) continue } guard row.amount == output.valueDuffs else { - anomalies.append((transaction, vout, output.valueDuffs, outpoint, "amount_mismatch")) + anomalies.append((transaction, vout, output.valueDuffs, outpoint, "amount_mismatch", outputIsCoinJoin)) continue } guard row.scriptPubKey == output.scriptPubkey else { - anomalies.append((transaction, vout, output.valueDuffs, outpoint, "script_mismatch")) + anomalies.append((transaction, vout, output.valueDuffs, outpoint, "script_mismatch", outputIsCoinJoin)) continue } validCount += 1 @@ -1075,9 +1118,14 @@ extension PlatformWalletPersistenceHandler { let truncatedAnomalyCount = anomalyGroups.values.reduce(0) { $0 + max(0, $1.count - CoreDiagnosticConstants.detailLimit) } - let missingCount = anomalies.filter { $0.reason == "missing_txo" }.count - let missingValue = diagnosticSaturatingSum(anomalies.compactMap { - $0.reason == "missing_txo" ? $0.amount : nil + let missing = anomalies.filter { $0.reason == "missing_txo" } + let missingCount = missing.filter { !$0.outputIsCoinJoin }.count + let missingValue = diagnosticSaturatingSum(missing.compactMap { + $0.outputIsCoinJoin ? nil : $0.amount + }) + let missingCoinJoinCount = missing.filter(\.outputIsCoinJoin).count + let missingCoinJoinValue = diagnosticSaturatingSum(missing.compactMap { + $0.outputIsCoinJoin ? $0.amount : nil }) // With no persisted BIP44 addresses nothing can be attributed to this // wallet: every decoded output falls into `unattributedOutputCount`, @@ -1089,9 +1137,12 @@ extension PlatformWalletPersistenceHandler { // `unattributedOutputCount > 0` is deliberately NOT part of this: a // CoinJoin-spending transaction pays its peers, and their outputs are // unattributable by construction, so every healthy audit has some. + // Since the pool now covers CoinJoin accounts too, that count is + // finally only peers and rows we genuinely lack — our own CoinJoin + // change no longer inflates it. // A partially lost pool is not distinguishable from a small one here; // `bip44_address_pool_size` sits beside this flag for that reading. - let addressPoolEmpty = bip44Addresses.isEmpty + let addressPoolEmpty = bip44AddressCount == 0 let auditIncomplete = decodeFailureCount > 0 || transactionBytesMissingCount > 0 || addressPoolEmpty @@ -1102,7 +1153,9 @@ extension PlatformWalletPersistenceHandler { fields: [ "audit_incomplete": .boolean(auditIncomplete), "bip44_address_pool_empty": .boolean(addressPoolEmpty), - "bip44_address_pool_size": .integer(Int64(bip44Addresses.count)), + "bip44_address_pool_size": .integer(Int64(bip44AddressCount)), + "coinjoin_to_coinjoin_missing_count": .integer(Int64(missingCoinJoinCount)), + "coinjoin_to_coinjoin_missing_value_duffs": .unsignedInteger(missingCoinJoinValue), "candidate_transaction_count": .integer(Int64(candidateCount)), "checkpoint": .publicText(checkpoint.rawValue), "coinjoin_to_bip44_missing_count": .integer(Int64(missingCount)), @@ -1113,6 +1166,8 @@ extension PlatformWalletPersistenceHandler { ), "owned_bip44_output_count": .integer(Int64(ownedOutputCount)), "owned_bip44_output_value_duffs": .unsignedInteger(ownedOutputValue), + "owned_coinjoin_output_count": .integer(Int64(ownedCoinJoinOutputCount)), + "owned_coinjoin_output_value_duffs": .unsignedInteger(ownedCoinJoinOutputValue), "persisted_valid_count": .integer(Int64(validCount)), "total_anomaly_count": .integer(Int64(anomalies.count)), "transaction_bytes_missing_count": .integer(Int64(transactionBytesMissingCount)), @@ -1133,7 +1188,9 @@ extension PlatformWalletPersistenceHandler { "checkpoint": .publicText(checkpoint.rawValue), "input_account_kind": .publicText("coinjoin"), "outpoint_reference": .reference(anomaly.outpoint), - "output_account_kind": .publicText("bip44"), + "output_account_kind": .publicText( + anomaly.outputIsCoinJoin ? "coinjoin" : "bip44" + ), "reason": .publicText(reason), "transaction_context": .unsignedInteger(UInt64(anomaly.tx.context)), "transaction_reference": .reference(anomaly.tx.txid), @@ -1339,6 +1396,16 @@ extension PlatformWalletManager { /// Coordinates the queue-owned SwiftData snapshot with read-only Rust FFI /// queries. Admission happens after the database await, then keeps the /// native handle alive until the off-main worker finishes. + /// + /// No caller inside this repository, by design: the artifact this produces + /// is a support export, and the only screen that asks for one lives in the + /// host app (dashpay/dashwallet-ios#1105 wires Contact Support to it). + /// `SwiftExampleApp` deliberately does not — it has no support flow, and a + /// demo button would make a pass documented as holding the persistence + /// queue look like something to press casually. Everything under the + /// `preExport` checkpoint is therefore reachable only through a host; the + /// `restoreBuffer` summary and `core_store_open_result` are the parts that + /// run unprompted. public func emitCoreWalletDiagnostics(for walletId: Data) async { let checkpoint = CoreWalletDiagnosticCheckpoint.preExport guard walletId.count == 32, let handler = persistence else { @@ -1362,6 +1429,11 @@ extension PlatformWalletManager { // cover costs the drain at most one stage. A manager with no handle // has nothing to drain; its database half still runs. let cancellation = coreDiagnosticsCancellation + // Counted for the whole pass, both halves, so a `shutdown()` that + // finds no handle can still tell a running database half to stop — + // and so one that finds neither leaves the latch down. + beginCoreDiagnosticsPass() + defer { endCoreDiagnosticsPass() } let admitted: Bool if isConfigured, handle != NULL_HANDLE { do { @@ -1455,17 +1527,45 @@ extension PlatformWalletManager { return true } + // `compareDatabase` and `compareAssetLocks` both emit a + // `diff_incomplete=true` summary rather than nothing when their input + // is missing, because an absent summary is indistinguishable from a + // log that was cut off mid-export. Every early return out of this + // function owes the reader the same line — otherwise grepping + // `core_db_memory_diff_summary` on a failed balance read finds + // silence, which reads as truncation. + func emitAbandonedDiffSummary(reason: String) { + SDKLogger.event( + "core_db_memory_diff_summary", + category: .persistence, + severity: .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "database_snapshot_available": .boolean(database != nil), + "diff_incomplete": .boolean(true), + "reason": .publicText(reason), + "wallet_reference": .reference(walletId), + ] + ) + } + // Keep the two Rust-memory sources independent: corrupt account state // must not suppress the AssetLock evidence that can explain a missing // balance (and vice versa). - if shutdownBegan(before: "asset_locks") { return } + if shutdownBegan(before: "asset_locks") { + emitAbandonedDiffSummary(reason: "shutdown_requested") + return + } compareAssetLocks( database, walletId: walletId, managedWallet: managedWallet, checkpoint: checkpoint ) - if shutdownBegan(before: "account_balances") { return } + if shutdownBegan(before: "account_balances") { + emitAbandonedDiffSummary(reason: "shutdown_requested") + return + } let balanceQuery = readAccountBalances( handle: managerHandle, walletId: walletId @@ -1481,6 +1581,7 @@ extension PlatformWalletManager { "wallet_reference": .reference(walletId), ] ) + emitAbandonedDiffSummary(reason: "account_balance_query_failed") return } @@ -1492,7 +1593,10 @@ extension PlatformWalletManager { ) } for balance in sortedBalances { - if shutdownBegan(before: "account_utxos") { return } + if shutdownBegan(before: "account_utxos") { + emitAbandonedDiffSummary(reason: "shutdown_requested") + return + } // One pool per account, matching `emitCoreWalletDatabaseDiagnostics`. // libdispatch drains its own pool once per work item, and this // whole loop is one work item: without this, every account's diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift index 966be86c8a4..d147c30f398 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift @@ -228,12 +228,17 @@ final class Dev1StoreUpgradeTests: XCTestCase { return XCTFail("a refused open must not rewrite the store") } } - /// The attribute-only downgrade: a store written by a build that added one - /// attribute to `PersistentWalletManagerMetadata` and kept V3's version - /// identifier. Its identifier is registered and every entity name is - /// known, so only the per-entity comparison can tell it from drift — and - /// must, because inferred migration would drop the attribute's values - /// without a word. + /// The attribute-only disagreement: a store whose + /// `PersistentWalletManagerMetadata` has one attribute the live model does + /// not, keeping V3's version identifier. Its identifier is registered and + /// every entity name is known, so only the per-entity comparison can tell + /// it from the pinned drift — and must, because inferred migration would + /// drop the attribute's values without a word. + /// + /// It is refused as `unplaceable`, NOT as a newer build: the same shape + /// arises the day an attribute is added to any unfrozen live model, where + /// every existing store is the OLDER one. So SwiftData's own error comes + /// through and the host never offers a reset off the back of it. func testStoreWithAnAttributeOnlyNewerEntityIsRefusedWithoutFallback() throws { let storeURL = directory.appendingPathComponent("DashModel.sqlite") try autoreleasepool { @@ -255,19 +260,19 @@ final class Dev1StoreUpgradeTests: XCTestCase { XCTAssertEqual( DashModelContainer.classifyStore(at: storeURL), - .newerThanRegistered(reason: "unexpected_entity_drift=PersistentWalletManagerMetadata") + .unplaceable(reason: "unexpected_entity_drift=PersistentWalletManagerMetadata") ) XCTAssertThrowsError(try DashModelContainer.open(configuration(at: storeURL))) { error in - XCTAssertEqual( + XCTAssertNil( error as? DashModelContainerError, - .storeFromNewerBuild(reason: "unexpected_entity_drift=PersistentWalletManagerMetadata") + "a disagreement with no direction must not claim a newer build: \(error)" ) } XCTAssertTrue(try logLines(event: "core_store_staged_migration_failed").isEmpty) let result = try XCTUnwrap(try logLines(event: "core_store_open_result").last) XCTAssertTrue(result.contains(#"result="failure""#), result) XCTAssertTrue( - result.contains("store_verdict=\"newer_than_registered:unexpected_entity_drift=PersistentWalletManagerMetadata\""), + result.contains("store_verdict=\"unplaceable:unexpected_entity_drift=PersistentWalletManagerMetadata\""), result ) } @@ -339,23 +344,35 @@ final class Dev1StoreUpgradeTests: XCTestCase { ) XCTAssertEqual( verdict(["PersistentWallet": a, "PersistentDocumentType": b, "PersistentIndex": knownIndex]), - .newerThanRegistered(reason: "unexpected_entity_drift=PersistentDocumentType"), - "a known entity with an unknown shape is a newer build, not drift" + .unplaceable(reason: "unexpected_entity_drift=PersistentDocumentType"), + "a known entity with an unknown shape is not the pinned drift — and not evidence of direction" ) // The attribute-only downgrade: same names, kept identifier, but the // disagreement is on an entity that is not known to have drifted. XCTAssertEqual( verdict(["PersistentWallet": b, "PersistentDocumentType": a, "PersistentIndex": a]), - .newerThanRegistered(reason: "unexpected_entity_drift=PersistentWallet") + .unplaceable(reason: "unexpected_entity_drift=PersistentWallet") ) // Mixed: known drift plus one unexpected entity still refuses. XCTAssertEqual( verdict(["PersistentWallet": b, "PersistentDocumentType": knownDocumentType, "PersistentIndex": a]), - .newerThanRegistered(reason: "unexpected_entity_drift=PersistentWallet") + .unplaceable(reason: "unexpected_entity_drift=PersistentWallet") + ) + XCTAssertEqual( + DashModelContainer.storeSchemaVerdict( + matchesRegisteredVersion: false, + storeEntityHashes: ["PersistentWallet": a], + storeVersionIdentifiers: ["1.0.0"], + registered: [], + currentEntities: current + ), + .unplaceable(reason: "no_registered_models"), + "no model built from any schema is a fact about this build, not the store" ) XCTAssertEqual( verdict(["PersistentWallet": a], identifiers: ["9.0.0"]), - .newerThanRegistered(reason: "unregistered_version_identifier=9.0.0") + .unplaceable(reason: "unregistered_version_identifier=9.0.0"), + "an identifier we do not register may be pre-V1 or de-registered, not only future" ) // Unplaceable, NOT a newer build: `open` must rethrow SwiftData's own // error for these rather than tell the user their wallet came from a From c0469ace3a0cf02a4308d0442abd7ab842d725cb Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 8 Sep 2026 15:07:55 +0200 Subject: [PATCH 14/17] fix(swift-sdk): drain only what this manager can finish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native-op admission exists to keep `handle` alive while an FFI read is in flight. The database half makes no FFI call — it reads SwiftData on the persistence serial queue — so covering it bought the handle nothing and cost `shutdown()`'s drain everything: the drain would wait on a block whose progress depends on that queue, and a wedged persister round is exactly when that queue does not advance. There is no deadline on the drain. Ordering does not come from the drain and survives without it. Native teardown runs on `destroyQueue`, and the Rust destroy's persister callbacks enter through `serialQueue.sync`, so they queue behind the scan rather than racing it — off the main thread, and bounded by the cancellation flag the scan polls between stages. ARC covers the object lifetimes: the block holds the handler and its container, so neither can be deallocated under the read. Admission is therefore back around the FFI half only, which runs on `coreDiagnosticsQueue` and polls cancellation between reads, so the drain waits on a stage this manager owns. The cancellation flag still covers the whole pass, and that — not the drain — is what closes the gap where a database half running for a manager with no handle could not be told to stop. This is the third time this admission has moved, so the reasoning is now in the code beside it rather than only in review. Co-Authored-By: Claude Opus 5 --- ...PlatformWalletManagerCoreDiagnostics.swift | 70 +++++++++++-------- 1 file changed, 40 insertions(+), 30 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift index e0df0add3e0..d49e7f7ff2b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift @@ -1421,51 +1421,45 @@ extension PlatformWalletManager { ) return } - // Admit BEFORE the database half, not after it: `shutdown()`'s drain - // must cover the whole export, or a teardown that begins during the - // cross-wallet scan proceeds while that scan still holds the - // persistence queue every persister callback enters through. The - // queue-confined pass polls `cancellation` between stages, so the - // cover costs the drain at most one stage. A manager with no handle - // has nothing to drain; its database half still runs. let cancellation = coreDiagnosticsCancellation // Counted for the whole pass, both halves, so a `shutdown()` that // finds no handle can still tell a running database half to stop — // and so one that finds neither leaves the latch down. beginCoreDiagnosticsPass() defer { endCoreDiagnosticsPass() } - let admitted: Bool - if isConfigured, handle != NULL_HANDLE { - do { - try admitCoreDiagnosticsNativeOp() - admitted = true - } catch { - SDKLogger.event( - "core_memory_snapshot_unavailable", - category: .persistence, - severity: .warning, - fields: [ - "checkpoint": .publicText(checkpoint.rawValue), - "reason": .publicText("manager_shutdown_in_progress"), - "wallet_reference": .reference(walletId), - ] - ) - return - } - } else { - admitted = false - } - defer { if admitted { finishCoreDiagnosticsNativeOp() } } // The database half may come back empty — wallet row missing, fetch // failed — and those are exactly the "coins gone from the database" // reports this exists for. The Rust half needs only the wallet id, so // it runs regardless and marks its diffs as one-sided. + // + // Deliberately OUTSIDE the native-op admission. That admission exists + // for one thing: keeping `handle` alive while an FFI read is in + // flight. This half makes no FFI call at all — it reads SwiftData on + // the persistence serial queue — so covering it buys the handle + // nothing and costs `shutdown()`'s drain everything: the drain would + // then wait on a block whose progress depends on that queue, and a + // wedged persister round (the failure this export exists to + // investigate) is exactly when the queue does not advance. There is + // no deadline on the drain, so that wait would be unbounded. + // + // Ordering is still safe without the drain, because it does not come + // from the drain: native teardown runs on `destroyQueue`, and the Rust + // destroy's persister callbacks enter through `serialQueue.sync`, so + // they queue BEHIND this block rather than racing it — off the main + // thread, and bounded by the cancellation flag this block polls + // between stages. ARC covers the rest: the block holds the handler and + // its container, so neither can be deallocated under the read. let database = await handler.emitCoreWalletDatabaseDiagnostics( walletId: walletId, cancellation: cancellation ) - guard admitted else { + + // Admission covers the FFI half only, which runs on + // `coreDiagnosticsQueue` and polls cancellation between reads — so the + // drain's "at most one stage in flight" is bounded by a stage this + // manager owns, not by whatever is holding the persistence queue. + guard isConfigured, handle != NULL_HANDLE else { SDKLogger.event( "core_memory_snapshot_unavailable", category: .persistence, @@ -1478,6 +1472,22 @@ extension PlatformWalletManager { ) return } + do { + try admitCoreDiagnosticsNativeOp() + } catch { + SDKLogger.event( + "core_memory_snapshot_unavailable", + category: .persistence, + severity: .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "reason": .publicText("manager_shutdown_in_progress"), + "wallet_reference": .reference(walletId), + ] + ) + return + } + defer { finishCoreDiagnosticsNativeOp() } let managerHandle = handle let managedWallet = wallets[walletId] From d0cddbc6bd42d60114492c3bb3404bfdbe5bd683 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 8 Sep 2026 15:16:52 +0200 Subject: [PATCH 15/17] test(swift-sdk): stop pinning the drift reason to the whole entity list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `testStoreWithAnAttributeOnlyNewerEntityIsRefusedWithoutFallback` asserted the reason string exactly, listing one entity. The fixture is a real store built from the live models, so the reason names every entity that disagrees — and merging the swept-transaction work added `PersistentPendingInput`, `PersistentTxo` and `PersistentWallet` to it. The test was a tripwire for unrelated schema work rather than a test of the classification. It now asserts the verdict and the entity it actually creates. Worth noting what the failure demonstrated: this is precisely the "add one attribute to an unfrozen live model and every existing store disagrees" case that moved this verdict from `newerThanRegistered` to `unplaceable`, arriving in the branch one merge later. Under the old classification this schema change would have started telling users their wallet came from a newer build and offering a reset. Co-Authored-By: Claude Opus 5 --- .../Dev1StoreUpgradeTests.swift | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift index d147c30f398..37906a28534 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift @@ -258,10 +258,20 @@ final class Dev1StoreUpgradeTests: XCTestCase { try context.save() } - XCTAssertEqual( - DashModelContainer.classifyStore(at: storeURL), - .unplaceable(reason: "unexpected_entity_drift=PersistentWalletManagerMetadata") - ) + // The reason lists every entity that disagrees, and this fixture is a + // real store built from the live models, so any change to any of them + // adds a name — as the swept-transaction work did to + // `PersistentPendingInput`, `PersistentTxo` and `PersistentWallet`. + // Assert the verdict and the entity this test actually creates, not + // the whole list, or this becomes a tripwire for unrelated schema work + // rather than a test of the classification. + guard case .unplaceable(let reason) = + DashModelContainer.classifyStore(at: storeURL) + else { + return XCTFail("an attribute-only disagreement must be unplaceable") + } + XCTAssertTrue(reason.hasPrefix("unexpected_entity_drift="), reason) + XCTAssertTrue(reason.contains("PersistentWalletManagerMetadata"), reason) XCTAssertThrowsError(try DashModelContainer.open(configuration(at: storeURL))) { error in XCTAssertNil( error as? DashModelContainerError, @@ -272,9 +282,10 @@ final class Dev1StoreUpgradeTests: XCTestCase { let result = try XCTUnwrap(try logLines(event: "core_store_open_result").last) XCTAssertTrue(result.contains(#"result="failure""#), result) XCTAssertTrue( - result.contains("store_verdict=\"unplaceable:unexpected_entity_drift=PersistentWalletManagerMetadata\""), + result.contains("store_verdict=\"unplaceable:unexpected_entity_drift="), result ) + XCTAssertTrue(result.contains("PersistentWalletManagerMetadata"), result) } /// `knownDriftedEntityHashes` must be exactly what the fixture shows, no From 5cbb7326e26da267c86af0181f9ff95090a6cb59 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 8 Sep 2026 15:34:29 +0200 Subject: [PATCH 16/17] =?UTF-8?q?fix(swift-sdk):=20drop=20the=20"newer=20b?= =?UTF-8?q?uild"=20claim=20entirely=20=E2=80=94=20nothing=20here=20has=20a?= =?UTF-8?q?=20direction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last route to `newerThanRegistered` was an entity the current schema lacks. That looks identical whether a newer build added it or an older build wrote one since renamed — and this SDK performed exactly such a rename, `PersistentUtxo` to `PersistentTxo`, documented a few hundred lines below the check. So the oldest stores in existence were the ones being told they came from the future, with a wallet reset offered as the remedy. There is no observable here that carries direction, so the verdict no longer claims one: everything unplaceable is `unplaceable`, and `DashModelContainerError.storeFromNewerBuild` is gone with its message. The refusal is unchanged — inferred migration is still never offered, which is what protects the data — only the explanation the host could act on destructively. The audit's own loops now poll cancellation, not just the stages around them. It is the most expensive stage in the pass (up to 10k decodes plus per-output relationship work), so a shutdown arriving mid-loop no longer waits for all of it; the audit reports that it gave up and the pass abandons the rest. And the empty-pool incompleteness check covers CoinJoin too. Widening `ownedAddresses` to CoinJoin accounts last round while leaving the check on BIP44 alone had opened a fourth route to the false all-clear: a lost CoinJoin address pool, on precisely the mixed wallet this audit exists for. Guarded on there being CoinJoin accounts at all, so an unmixed wallet is not flagged. Co-Authored-By: Claude Opus 5 --- .../Persistence/DashModelContainer.swift | 70 ++++++++----------- ...PlatformWalletManagerCoreDiagnostics.swift | 36 ++++++++-- .../Dev1StoreUpgradeTests.swift | 39 ++++++----- 3 files changed, 84 insertions(+), 61 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 4cc45ca47d9..18c89e80a67 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -11,21 +11,11 @@ public enum DashModelContainerError: LocalizedError, Equatable { /// from the SDK's. `unexpected` names entities the SDK schema lacks; /// `missing` names SDK entities the configuration lacks. case schemaMismatch(unexpected: [String], missing: [String]) - /// The store was written by a build with a newer schema than this SDK - /// registers (`reason` says how that was detected). Opening it with - /// inferred migration would silently drop what the newer build wrote, - /// so `open` refuses; the only safe ways forward are a newer build or a - /// wallet reset. - case storeFromNewerBuild(reason: String) - public var errorDescription: String? { switch self { case .schemaMismatch(let unexpected, let missing): return "The store configuration's schema does not match the SDK schema" + " (unexpected: \(unexpected.joined(separator: ", ")); missing: \(missing.joined(separator: ", ")))." - case .storeFromNewerBuild: - return "The wallet database on this device was written by a newer version of the app." - + " This version cannot open it without losing data; update the app, or reset the wallet." } } } @@ -73,21 +63,25 @@ public enum DashModelContainer { /// lists (every v4.2.0-dev.1 store, until the remaining V1/V2 shapes /// are frozen). Inferred migration may open it. case driftedRegisteredVersion - /// Positively written by a NEWER build: the store carries an entity - /// this schema does not have, which nothing older could have created. - /// Inferred migration would open it and silently drop that entity's - /// table, so it must not run; the pre-fallback crash was the safe - /// outcome here. This is the only verdict the host may turn into - /// "update the app", because it is the only one whose evidence has a - /// direction — see `unplaceable` for why a hash disagreement does not. - case newerThanRegistered(reason: String) /// The metadata reads but does not place the store against any - /// registered version: no declared version identifier, or nothing to - /// compare it by. Inferred migration must not answer this either — an - /// unplaced store opened by inference is trimmed to the current schema - /// exactly like a downgrade — but it is NOT evidence of a newer build, - /// so the host must not be told to update or reset. SwiftData's own - /// error is passed through instead. + /// registered version. Inferred migration must not answer this — an + /// unplaced store opened by inference is trimmed to the current + /// schema, dropping whatever it cannot map — so `open` refuses and + /// rethrows SwiftData's own error. + /// + /// There is deliberately no "written by a newer build" verdict beside + /// this one. Nothing observable here carries a direction. A hash + /// disagreement says two shapes differ, not which came first. An + /// entity the current schema lacks looks identical whether a newer + /// build added it or an older build wrote one since renamed — and this + /// SDK has done exactly that rename (`PersistentUtxo` → `PersistentTxo`, + /// see the migration notes below), so the store that would have been + /// called "from the future" is one of the oldest that exists. An + /// unregistered version identifier is ambiguous for the same reason. + /// + /// So the classification says only that it cannot place the store, + /// never why, and the host has nothing to turn into a reset prompt. + /// `reason` still records which check refused, for the log. case unplaceable(reason: String) var logLabel: String { @@ -95,7 +89,6 @@ public enum DashModelContainer { case .unreadable: return "unreadable" case .matchesRegisteredVersion: return "matches_registered_version" case .driftedRegisteredVersion: return "drifted_registered_version" - case .newerThanRegistered(let reason): return "newer_than_registered:\(reason)" case .unplaceable(let reason): return "unplaceable:\(reason)" } } @@ -202,13 +195,14 @@ public enum DashModelContainer { return .unplaceable(reason: "no_entity_hashes") } - // An entity the current schema does not have can only have been - // written by a newer build — this is the one asymmetric fact - // available here, so it is the one verdict allowed to say "newer". - // Inferred migration would drop its table. + // An entity the current schema does not have. Inferred migration would + // drop its table, so the store is refused — but not as a newer build: + // a rename leaves an older store carrying a name this schema no longer + // has, and `PersistentUtxo` → `PersistentTxo` is one this SDK actually + // performed. let unknownEntities = Set(storeEntityHashes.keys).subtracting(currentEntities).sorted() if !unknownEntities.isEmpty { - return .newerThanRegistered( + return .unplaceable( reason: "unknown_entities=\(unknownEntities.joined(separator: "|"))" ) } @@ -581,16 +575,12 @@ public enum DashModelContainer { : .unreadable guard case .driftedRegisteredVersion = verdict else { report(succeeded: false, migrationPath: .staged, error: error, storeVerdict: verdict) - // A newer build's store is the one refusal the host can act - // on, so it gets a typed error — and only it, because that - // error's text tells the user to update the app or reset the - // wallet, and resetting is destructive on a store that is - // merely unplaceable. `.unplaceable` and `.unreadable` are - // SwiftData's own failure, passed through untouched with the - // verdict in the log. - if case .newerThanRegistered(let reason) = verdict { - throw DashModelContainerError.storeFromNewerBuild(reason: reason) - } + // No typed error for any of these. Every refusal here means + // "this store cannot be placed", never "this store is newer" — + // the evidence does not distinguish them — and only the second + // would justify telling a user to update or reset. SwiftData's + // own failure goes through untouched, with the verdict in the + // log for whoever reads the export. throw error } SDKLogger.event( diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift index d49e7f7ff2b..f51226e24bc 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift @@ -613,13 +613,20 @@ extension PlatformWalletPersistenceHandler { return nil } if let allTransactions { - Self.auditCoinJoinOwnedBip44Outputs( + let gaveUp = Self.auditCoinJoinOwnedBip44Outputs( wallet: wallet, walletId: walletId, checkpoint: checkpoint, allTxos: allTxos, - allTransactions: allTransactions + allTransactions: allTransactions, + isCancelled: { cancellation?.isCancelled == true } ) + // It stopped mid-loop rather than between stages, so the rest + // of the pass is abandoned the same way every other stage + // abandons it — the summary it did not emit is the signal. + if gaveUp, shutdownBegan(before: "owned_output_audit_interrupted") { + return nil + } } if shutdownBegan(before: "asset_lock_snapshot") { return nil } @@ -927,13 +934,19 @@ extension PlatformWalletPersistenceHandler { /// or one whose address row was never written, is attributable to nobody /// and lands in `unattributed_output_count` instead — which is why the /// summary reports that counter and the pool size next to the verdict. + /// - Parameter isCancelled: polled inside the long loops, not only around + /// them. This is the most expensive stage in the pass — up to + /// `exactAuditTransactionRows` decodes plus per-output relationship work + /// — so a `shutdown()` that arrives mid-loop must not wait for the whole + /// of it. Returns true if the audit gave up, and the caller says so. private static func auditCoinJoinOwnedBip44Outputs( wallet: PersistentWallet, walletId: Data, checkpoint: CoreWalletDiagnosticCheckpoint, allTxos: [PersistentTxo], - allTransactions: [PersistentTransaction] - ) { + allTransactions: [PersistentTransaction], + isCancelled: () -> Bool + ) -> Bool { // Match the wallet exactly as `walletTxos` does. Accepting only the // relationship would drop a CoinJoin row whose `account.wallet` link is // broken — the very corruption this audit exists to expose — and its @@ -971,6 +984,7 @@ extension PlatformWalletPersistenceHandler { } } let bip44AddressCount = bip44Accounts.reduce(0) { $0 + $1.coreAddresses.count } + let coinJoinAddressCount = coinJoinAccounts.reduce(0) { $0 + $1.coreAddresses.count } let txoByOutpoint = Dictionary(grouping: allTxos, by: \.outpoint) var candidateCount = 0 @@ -997,10 +1011,11 @@ extension PlatformWalletPersistenceHandler { "wallet_reference": .reference(walletId), ] ) - return + return false } for transaction in allTransactions { + if isCancelled() { return true } // A stub row with no consensus bytes is a real production state // (an orphaned upsert reads back as empty) — and the half-written // persistence #4438 is about. It cannot be decoded, so it cannot @@ -1035,6 +1050,7 @@ extension PlatformWalletPersistenceHandler { candidateCount += 1 for (index, output) in decoded.outputs.enumerated() { + if isCancelled() { return true } guard let address = output.address else { // Non-P2PKH/P2SH scriptPubKey: nothing to match against the // address pool, so it is unclassified rather than foreign. @@ -1142,7 +1158,15 @@ extension PlatformWalletPersistenceHandler { // change no longer inflates it. // A partially lost pool is not distinguishable from a small one here; // `bip44_address_pool_size` sits beside this flag for that reading. + // Both pools, now that both are audited. A wallet always has BIP44 + // accounts, so an empty BIP44 pool is unconditionally wrong; CoinJoin + // accounts only exist on a mixed wallet, so an empty CoinJoin pool is + // only evidence when there are accounts that should have filled it. + // Without the second clause, widening `ownedAddresses` to CoinJoin + // would have added a fourth route to the false all-clear: a lost + // CoinJoin pool, on the mixed wallet this audit is written for. let addressPoolEmpty = bip44AddressCount == 0 + || (!coinJoinAccounts.isEmpty && coinJoinAddressCount == 0) let auditIncomplete = decodeFailureCount > 0 || transactionBytesMissingCount > 0 || addressPoolEmpty @@ -1154,6 +1178,7 @@ extension PlatformWalletPersistenceHandler { "audit_incomplete": .boolean(auditIncomplete), "bip44_address_pool_empty": .boolean(addressPoolEmpty), "bip44_address_pool_size": .integer(Int64(bip44AddressCount)), + "coinjoin_address_pool_size": .integer(Int64(coinJoinAddressCount)), "coinjoin_to_coinjoin_missing_count": .integer(Int64(missingCoinJoinCount)), "coinjoin_to_coinjoin_missing_value_duffs": .unsignedInteger(missingCoinJoinValue), "candidate_transaction_count": .integer(Int64(candidateCount)), @@ -1200,6 +1225,7 @@ extension PlatformWalletPersistenceHandler { ) } } + return false } /// Picks the row that represents one outpoint when the table holds more diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift index 37906a28534..d32df34bc7d 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift @@ -185,12 +185,17 @@ final class Dev1StoreUpgradeTests: XCTestCase { XCTAssertFalse(result.contains(storeURL.path), result) } - /// A store written by a newer build — here, one with an entity this SDK - /// does not have — fails the staged open like a drifted store does, but - /// must NOT be handed to inferred migration: that would open it and drop - /// the unknown entity's table without a word. The pre-fallback crash was - /// the safe outcome for a downgrade, and it must stay one. - func testStoreFromANewerSchemaIsRefusedWithoutFallback() throws { + /// A store carrying an entity this SDK does not have fails the staged open + /// like a drifted store does, but must NOT be handed to inferred + /// migration: that would open it and drop the entity's table without a + /// word. The pre-fallback crash was the safe outcome, and it stays one. + /// + /// It is refused as `unplaceable`, and the refusal carries no typed error: + /// an unknown entity name does not say which build is older. This SDK + /// renamed `PersistentUtxo` to `PersistentTxo`, so a store predating that + /// rename looks exactly like this one — and telling its owner to update + /// the app or reset the wallet would be wrong and destructive. + func testStoreWithAnUnknownEntityIsRefusedWithoutFallback() throws { let storeURL = directory.appendingPathComponent("DashModel.sqlite") try autoreleasepool { let newer = Schema(DashModelContainer.modelTypes + [FutureOnlyModel.self]) @@ -206,25 +211,25 @@ final class Dev1StoreUpgradeTests: XCTestCase { try context.save() } - guard case .newerThanRegistered(let reason) = DashModelContainer.classifyStore(at: storeURL) + guard case .unplaceable(let reason) = DashModelContainer.classifyStore(at: storeURL) else { - return XCTFail("a store with an unknown entity must classify as newer") + return XCTFail("a store with an unknown entity must classify as unplaceable") } XCTAssertTrue(reason.contains("FutureOnlyModel"), reason) XCTAssertThrowsError(try DashModelContainer.open(configuration(at: storeURL))) { error in - guard case DashModelContainerError.storeFromNewerBuild(let reason) = error else { - return XCTFail("a newer store must surface as the typed error, got \(error)") - } - XCTAssertTrue(reason.contains("FutureOnlyModel"), reason) + XCTAssertNil( + error as? DashModelContainerError, + "an unknown entity name carries no direction and must not claim one: \(error)" + ) } XCTAssertTrue(try logLines(event: "core_store_staged_migration_failed").isEmpty) let result = try XCTUnwrap(try logLines(event: "core_store_open_result").last) XCTAssertTrue(result.contains(#"migration_path="staged""#), result) XCTAssertTrue(result.contains(#"result="failure""#), result) - XCTAssertTrue(result.contains("store_verdict=\"newer_than_registered:"), result) - // And the store is untouched: still newer, still refused. - guard case .newerThanRegistered = DashModelContainer.classifyStore(at: storeURL) else { + XCTAssertTrue(result.contains("store_verdict=\"unplaceable:unknown_entities="), result) + // And the store is untouched: still unplaceable, still refused. + guard case .unplaceable = DashModelContainer.classifyStore(at: storeURL) else { return XCTFail("a refused open must not rewrite the store") } } @@ -405,7 +410,9 @@ final class Dev1StoreUpgradeTests: XCTestCase { ) XCTAssertEqual( verdict(["PersistentWallet": a, "FutureOnlyModel": a]), - .newerThanRegistered(reason: "unknown_entities=FutureOnlyModel") + .unplaceable(reason: "unknown_entities=FutureOnlyModel"), + "an entity this schema lacks may be a newer build's addition or an " + + "older build's since-renamed model (PersistentUtxo -> PersistentTxo)" ) } } From c686477167cb8947568d8e8cf510cf99da097490 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 8 Sep 2026 16:06:53 +0200 Subject: [PATCH 17/17] fix(swift-sdk): judge each address pool only where accounts should have filled it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The empty-pool check judged BIP44 unconditionally, so a CoinJoin-only wallet reported `audit_incomplete=true` although attribution had worked — noise in the one field an analyst uses to decide whether to keep reading. It is now symmetric: a pool counts as evidence only when there are accounts that should have filled it, plus an unconditional clause for owning no addresses at all, where nothing can be attributed however few accounts exist. `bip44_address_pool_empty` is renamed `address_pool_empty` to match what it now covers. Co-Authored-By: Claude Opus 5 --- ...PlatformWalletManagerCoreDiagnostics.swift | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift index f51226e24bc..471ad5c8622 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift @@ -1158,14 +1158,15 @@ extension PlatformWalletPersistenceHandler { // change no longer inflates it. // A partially lost pool is not distinguishable from a small one here; // `bip44_address_pool_size` sits beside this flag for that reading. - // Both pools, now that both are audited. A wallet always has BIP44 - // accounts, so an empty BIP44 pool is unconditionally wrong; CoinJoin - // accounts only exist on a mixed wallet, so an empty CoinJoin pool is - // only evidence when there are accounts that should have filled it. - // Without the second clause, widening `ownedAddresses` to CoinJoin - // would have added a fourth route to the false all-clear: a lost - // CoinJoin pool, on the mixed wallet this audit is written for. - let addressPoolEmpty = bip44AddressCount == 0 + // Per pool, and symmetrically: an empty pool is evidence only where + // there are accounts that should have filled it. Judging BIP44 + // unconditionally called a CoinJoin-only wallet incomplete although + // attribution had worked — noise rather than danger, but noise in the + // one field an analyst uses to decide whether to keep reading. + // Nothing owned at all is incomplete however few accounts exist: + // no address means nothing can be attributed. + let addressPoolEmpty = ownedAddresses.isEmpty + || (!bip44Accounts.isEmpty && bip44AddressCount == 0) || (!coinJoinAccounts.isEmpty && coinJoinAddressCount == 0) let auditIncomplete = decodeFailureCount > 0 || transactionBytesMissingCount > 0 @@ -1176,7 +1177,7 @@ extension PlatformWalletPersistenceHandler { severity: anomalies.isEmpty && !auditIncomplete ? .info : .warning, fields: [ "audit_incomplete": .boolean(auditIncomplete), - "bip44_address_pool_empty": .boolean(addressPoolEmpty), + "address_pool_empty": .boolean(addressPoolEmpty), "bip44_address_pool_size": .integer(Int64(bip44AddressCount)), "coinjoin_address_pool_size": .integer(Int64(coinJoinAddressCount)), "coinjoin_to_coinjoin_missing_count": .integer(Int64(missingCoinJoinCount)),