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 01c5cfd5867..1b43111d2a7 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -92,7 +92,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 /// Context dedicated to tracked-masternode whole-set writes. Those writes /// are not part of a wallet changeset and must become durable before their @@ -106,7 +109,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 ) @@ -146,6 +152,12 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// like all other mutable handler state. private var deferredPaymentUpserts: [(ownerIdentityId: Data, payments: [DashPayPayment])] = [] + /// One value-only pre-restore snapshot per wallet. The immediate + /// post-restore comparison consumes this copy instead of re-fetching and + /// re-decoding the same SwiftData history a second time during launch. + /// Confined to `serialQueue` with the rest of the handler state. + var startupCoreDiagnosticSnapshots: [Data: CoreWalletDatabaseDiagnosticSnapshot] = [:] + public init(modelContainer: ModelContainer, network: Network? = nil) { self.modelContainer = modelContainer self.network = network @@ -185,12 +197,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. @@ -5042,6 +5067,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 @@ -5089,6 +5124,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 @@ -5129,9 +5175,13 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } unspentBuckets.reserveCapacity(restorable.count) for row in unspent { - 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 @@ -5368,6 +5418,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 @@ -5459,6 +5515,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 00000000000..836d5beeb5a Binary files /dev/null and b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/DashModel-v4.2.0-dev.1.sqlite.zlib differ diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/README.md b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/README.md new file mode 100644 index 00000000000..7bded8cbd46 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/README.md @@ -0,0 +1,13 @@ +# v4.2.0-dev.1 migration fixture + +`DashModel-v4.2.0-dev.1.sqlite.zlib` is a synthetic SwiftData store created +from the exact `v4.2.0-dev.1` model sources. It contains one wallet and one +BIP44 account with non-secret marker bytes; it contains no production wallet +material. + +- uncompressed SQLite size: `647168` bytes +- 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.