From 7685ec7126f317045655184e2f84696ff47f1e23 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Tue, 1 Sep 2026 16:30:26 +0200 Subject: [PATCH] feat(swift-sdk): add core wallet balance diagnostics --- packages/swift-sdk/Package.swift | 3 +- .../Persistence/DashModelContainer.swift | 110 +- .../CoreWalletDiagnosticAnalyzers.swift | 451 +++++ .../PlatformWalletManager.swift | 30 +- ...PlatformWalletManagerCoreDiagnostics.swift | 1551 +++++++++++++++++ .../PlatformWalletManagerSPV.swift | 64 +- .../PlatformWalletPersistenceHandler.swift | 65 +- .../CoreWalletDiagnosticAnalyzerTests.swift | 380 ++++ .../CoreWalletDiagnosticsTests.swift | 300 ++++ .../Dev1StoreUpgradeTests.swift | 75 + .../DashModel-v4.2.0-dev.1.sqlite.zlib | Bin 0 -> 62566 bytes .../SwiftDashSDKTests/Fixtures/README.md | 13 + 12 files changed, 3025 insertions(+), 17 deletions(-) create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWalletDiagnosticAnalyzers.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticAnalyzerTests.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreWalletDiagnosticsTests.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Dev1StoreUpgradeTests.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/DashModel-v4.2.0-dev.1.sqlite.zlib create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/Fixtures/README.md diff --git a/packages/swift-sdk/Package.swift b/packages/swift-sdk/Package.swift index 253d84fcccd..9bc528b370b 100644 --- a/packages/swift-sdk/Package.swift +++ b/packages/swift-sdk/Package.swift @@ -32,7 +32,8 @@ let package = Package( .testTarget( name: "SwiftDashSDKTests", dependencies: ["SwiftDashSDK"], - path: "SwiftTests/SwiftDashSDKTests" + path: "SwiftTests/SwiftDashSDKTests", + resources: [.copy("Fixtures")] ), // Integration tests against a local dashmate devnet. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 0126c3d65be..a6e5dd9bd38 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -3,6 +3,37 @@ import SwiftData /// Factory for creating SwiftData model containers for Dash Platform persistence public enum DashModelContainer { + private struct StoreFileSizes { + let main: UInt64 + let wal: UInt64 + let shm: UInt64 + + var total: UInt64 { + [main, wal, shm].reduce(0) { partial, value in + let (sum, overflow) = partial.addingReportingOverflow(value) + return overflow ? UInt64.max : sum + } + } + } + + /// SQLite's durable state can be mostly in the WAL immediately after an + /// app kill, so the main file alone is not a useful corruption signal. + /// Read only sizes and never include any component of the device path. + private static func storeFileSizes(at storeURL: URL) -> StoreFileSizes { + func fileSize(at url: URL) -> UInt64 { + guard let size = try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize, + size >= 0 + else { return 0 } + return UInt64(size) + } + + return StoreFileSizes( + main: fileSize(at: storeURL), + wal: fileSize(at: URL(fileURLWithPath: storeURL.path + "-wal")), + shm: fileSize(at: URL(fileURLWithPath: storeURL.path + "-shm")) + ) + } + /// Every registered schema version's model list, parameterised on the /// one model whose shape differs between versions. /// @@ -97,12 +128,79 @@ public enum DashModelContainer { ) // Always wire the migration plan so stores created by an older SDK - // advance through the registered versioned schemas. - return try ModelContainer( - for: schema, - migrationPlan: DashMigrationPlan.self, - configurations: [modelConfiguration] - ) + // advance through the registered versioned schemas. Record only + // metadata about the store — never its device path. + let storeURL = modelConfiguration.url + let existedBefore = FileManager.default.fileExists(atPath: storeURL.path) + let sizeBefore = storeFileSizes(at: storeURL) + let started = CFAbsoluteTimeGetCurrent() + do { + let container = try ModelContainer( + for: schema, + migrationPlan: DashMigrationPlan.self, + configurations: [modelConfiguration] + ) + let sizeAfter = storeFileSizes(at: storeURL) + SDKLogger.event( + "core_store_open_result", + category: .persistence, + fields: [ + "container_result": .publicText("opened"), + "container_reused": .boolean(false), + "duration_ms": .unsignedInteger(UInt64(max( + 0, + Int((CFAbsoluteTimeGetCurrent() - started) * 1_000) + ))), + "migration_result": .publicText( + existedBefore ? "store_open_succeeded" : "not_required_new_store" + ), + "result": .publicText("success"), + "store_existed_before_open": .boolean(existedBefore), + "store_main_size_bytes_after": .unsignedInteger(sizeAfter.main), + "store_main_size_bytes_before": .unsignedInteger(sizeBefore.main), + "store_shm_size_bytes_after": .unsignedInteger(sizeAfter.shm), + "store_shm_size_bytes_before": .unsignedInteger(sizeBefore.shm), + "store_size_bytes_after": .unsignedInteger(sizeAfter.total), + "store_size_bytes_before": .unsignedInteger(sizeBefore.total), + "store_wal_size_bytes_after": .unsignedInteger(sizeAfter.wal), + "store_wal_size_bytes_before": .unsignedInteger(sizeBefore.wal), + ] + ) + return container + } catch { + let sizeAfter = storeFileSizes(at: storeURL) + SDKLogger.event( + "core_store_open_result", + category: .persistence, + severity: .error, + fields: [ + "container_result": .publicText("open_failed"), + "container_reused": .boolean(false), + "duration_ms": .unsignedInteger(UInt64(max( + 0, + Int((CFAbsoluteTimeGetCurrent() - started) * 1_000) + ))), + "migration_result": .publicText( + existedBefore + ? "store_open_or_migration_failed" + : "not_attempted_new_store_create_failed" + ), + "result": .publicText("failure"), + "store_existed_before_open": .boolean(existedBefore), + "store_main_size_bytes_after": .unsignedInteger(sizeAfter.main), + "store_main_size_bytes_before": .unsignedInteger(sizeBefore.main), + "store_shm_size_bytes_after": .unsignedInteger(sizeAfter.shm), + "store_shm_size_bytes_before": .unsignedInteger(sizeBefore.shm), + "store_size_bytes_after": .unsignedInteger(sizeAfter.total), + "store_size_bytes_before": .unsignedInteger(sizeBefore.total), + "store_wal_size_bytes_after": .unsignedInteger(sizeAfter.wal), + "store_wal_size_bytes_before": .unsignedInteger(sizeBefore.wal), + ], + error: error, + redacting: [storeURL.path] + ) + throw error + } } /// Create an in-memory model container for testing diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWalletDiagnosticAnalyzers.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWalletDiagnosticAnalyzers.swift new file mode 100644 index 00000000000..70dc2540e65 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreWalletDiagnosticAnalyzers.swift @@ -0,0 +1,451 @@ +import Foundation + +/// Value-only analyzers shared by the diagnostic logger and its unit tests. +/// Keeping comparison and truncation here makes the tests exercise the exact +/// decisions that produce `swift/run.log`, without requiring a live Rust +/// wallet handle. +enum CoreWalletDiagnosticAnalyzer { + struct TxoDiffDetail: Sendable { + let outpoint: Data + let reason: String + let row: CoreWalletDatabaseDiagnosticSnapshot.Txo + } + + struct TxoDiff: Sendable { + let commonCount: Int + let databaseAccountOnlyCount: Int + let memoryAccountOnlyCount: Int + let databaseOnlyCount: Int + let memoryOnlyCount: Int + let fieldMismatchCount: Int + let details: [TxoDiffDetail] + let emittedDetails: [TxoDiffDetail] + let truncatedCount: Int + } + + static func compareTxos( + database: [CoreWalletDatabaseDiagnosticSnapshot.Txo], + memory: [CoreWalletDatabaseDiagnosticSnapshot.Txo], + databaseAccounts: Set, + memoryAccounts: Set + ) -> TxoDiff { + var databaseByOutpoint: [Data: CoreWalletDatabaseDiagnosticSnapshot.Txo] = [:] + for row in database.sorted(by: txoOrder) where databaseByOutpoint[row.outpoint] == nil { + databaseByOutpoint[row.outpoint] = row + } + var memoryByOutpoint: [Data: CoreWalletDatabaseDiagnosticSnapshot.Txo] = [:] + for row in memory.sorted(by: txoOrder) where memoryByOutpoint[row.outpoint] == nil { + memoryByOutpoint[row.outpoint] = row + } + + let databaseOnly = databaseByOutpoint.keys + .filter { memoryByOutpoint[$0] == nil } + .sorted { $0.lexicographicallyPrecedes($1) } + let memoryOnly = memoryByOutpoint.keys + .filter { databaseByOutpoint[$0] == nil } + .sorted { $0.lexicographicallyPrecedes($1) } + + var mismatchDetails: [TxoDiffDetail] = [] + for outpoint in databaseByOutpoint.keys.sorted(by: { $0.lexicographicallyPrecedes($1) }) { + guard let databaseRow = databaseByOutpoint[outpoint], + let memoryRow = memoryByOutpoint[outpoint] + else { continue } + if databaseRow.amount != memoryRow.amount { + mismatchDetails.append(.init( + outpoint: outpoint, + reason: "amount_mismatch", + row: databaseRow + )) + } + if databaseRow.height != memoryRow.height { + mismatchDetails.append(.init( + outpoint: outpoint, + reason: "height_mismatch", + row: databaseRow + )) + } + if databaseRow.scriptPubKey != memoryRow.scriptPubKey { + mismatchDetails.append(.init( + outpoint: outpoint, + reason: "script_mismatch", + row: databaseRow + )) + } + if databaseRow.isLocked != memoryRow.isLocked { + mismatchDetails.append(.init( + outpoint: outpoint, + reason: "lock_mismatch", + row: databaseRow + )) + } + if databaseRow.account != memoryRow.account { + mismatchDetails.append(.init( + outpoint: outpoint, + reason: "account_mismatch", + row: databaseRow + )) + } + } + + var details = databaseOnly.compactMap { outpoint in + databaseByOutpoint[outpoint].map { + TxoDiffDetail(outpoint: outpoint, reason: "database_only", row: $0) + } + } + details.append(contentsOf: memoryOnly.compactMap { outpoint in + memoryByOutpoint[outpoint].map { + TxoDiffDetail(outpoint: outpoint, reason: "memory_only", row: $0) + } + }) + details.append(contentsOf: mismatchDetails) + details.sort(by: txoDetailOrder) + let limited = limitedTxoDetails(details) + + return TxoDiff( + commonCount: Set(databaseByOutpoint.keys).intersection(memoryByOutpoint.keys).count, + databaseAccountOnlyCount: databaseAccounts.subtracting(memoryAccounts).count, + memoryAccountOnlyCount: memoryAccounts.subtracting(databaseAccounts).count, + databaseOnlyCount: databaseOnly.count, + memoryOnlyCount: memoryOnly.count, + fieldMismatchCount: mismatchDetails.count, + details: details, + emittedDetails: limited.emitted, + truncatedCount: limited.truncated + ) + } + + struct AssetLockDiffDetail: Sendable { + let outpointDisplay: String + let reason: String + } + + struct AssetLockDiff: Sendable { + let details: [AssetLockDiffDetail] + let emittedDetails: [AssetLockDiffDetail] + let truncatedCount: Int + } + + static func compareAssetLocks( + database: [CoreWalletDatabaseDiagnosticSnapshot.AssetLock], + memory: [CoreWalletDatabaseDiagnosticSnapshot.AssetLock] + ) -> AssetLockDiff { + var databaseByOutpoint: [String: CoreWalletDatabaseDiagnosticSnapshot.AssetLock] = [:] + for row in database.sorted(by: assetLockOrder) + where databaseByOutpoint[row.outpointDisplay] == nil { + databaseByOutpoint[row.outpointDisplay] = row + } + var memoryByOutpoint: [String: CoreWalletDatabaseDiagnosticSnapshot.AssetLock] = [:] + for row in memory.sorted(by: assetLockOrder) + where memoryByOutpoint[row.outpointDisplay] == nil { + memoryByOutpoint[row.outpointDisplay] = row + } + + var details: [AssetLockDiffDetail] = [] + for outpoint in databaseByOutpoint.keys where memoryByOutpoint[outpoint] == nil { + details.append(.init(outpointDisplay: outpoint, reason: "database_only")) + } + for outpoint in memoryByOutpoint.keys where databaseByOutpoint[outpoint] == nil { + details.append(.init(outpointDisplay: outpoint, reason: "memory_only")) + } + for outpoint in databaseByOutpoint.keys.sorted() { + guard let databaseRow = databaseByOutpoint[outpoint], + let memoryRow = memoryByOutpoint[outpoint] + else { continue } + if databaseRow.fundingType != memoryRow.fundingType { + details.append(.init(outpointDisplay: outpoint, reason: "funding_type_mismatch")) + } + if databaseRow.status != memoryRow.status { + details.append(.init(outpointDisplay: outpoint, reason: "status_mismatch")) + } + if databaseRow.accountIndex != memoryRow.accountIndex { + details.append(.init(outpointDisplay: outpoint, reason: "account_index_mismatch")) + } + if databaseRow.registrationIndex != memoryRow.registrationIndex { + details.append(.init( + outpointDisplay: outpoint, + reason: "registration_index_mismatch" + )) + } + if databaseRow.amountDuffs != memoryRow.amountDuffs { + details.append(.init(outpointDisplay: outpoint, reason: "amount_mismatch")) + } + if databaseRow.hasProof != memoryRow.hasProof { + details.append(.init( + outpointDisplay: outpoint, + reason: "proof_presence_mismatch" + )) + } + } + details.sort(by: assetLockDetailOrder) + let limited = limitedAssetLockDetails(details) + return AssetLockDiff( + details: details, + emittedDetails: limited.emitted, + truncatedCount: limited.truncated + ) + } + + struct RestoreCandidate: Sendable { + enum RejectionReason: String, Sendable { + case missingAccount = "missing_account" + case invalidTxid = "invalid_txid" + case invalidAccountType = "invalid_account_type" + } + + let txo: CoreWalletDatabaseDiagnosticSnapshot.Txo + let accountType: UInt32? + let standardTag: UInt8? + let rejectionReason: RejectionReason? + let isCoinbase: Bool + let isConfirmed: Bool + let isInstantLocked: Bool + } + + struct RestoreBufferSummary: Sendable { + let candidateCount: Int + let candidateValueDuffs: UInt64 + let candidateBip44Count: Int + let candidateBip44ValueDuffs: UInt64 + let candidateCoinJoinCount: Int + let candidateCoinJoinValueDuffs: UInt64 + let builtCount: Int + let emittedCandidates: [RestoreCandidate] + let emittedValueDuffs: UInt64 + let emittedBip44Count: Int + let emittedBip44ValueDuffs: UInt64 + let emittedCoinJoinCount: Int + let emittedCoinJoinValueDuffs: UInt64 + let missingAccountCount: Int + let invalidTxidCount: Int + let invalidAccountTypeCount: Int + } + + static func summarizeRestoreBuffer( + candidates: [RestoreCandidate], + emittedCount: Int, + errored: Bool + ) -> RestoreBufferSummary { + let valid = candidates.filter { $0.rejectionReason == nil } + let emittedCandidates = errored ? [] : Array(valid.prefix(max(0, emittedCount))) + let candidateBip44 = candidates.filter { + $0.accountType == 0 && $0.standardTag == 0 + } + let candidateCoinJoin = candidates.filter { $0.accountType == 1 } + let emittedBip44 = emittedCandidates.filter { + $0.accountType == 0 && $0.standardTag == 0 + } + let emittedCoinJoin = emittedCandidates.filter { $0.accountType == 1 } + return RestoreBufferSummary( + candidateCount: candidates.count, + candidateValueDuffs: diagnosticSaturatingSum(candidates.map(\.txo.amount)), + candidateBip44Count: candidateBip44.count, + candidateBip44ValueDuffs: diagnosticSaturatingSum( + candidateBip44.map(\.txo.amount) + ), + candidateCoinJoinCount: candidateCoinJoin.count, + candidateCoinJoinValueDuffs: diagnosticSaturatingSum( + candidateCoinJoin.map(\.txo.amount) + ), + builtCount: emittedCount, + emittedCandidates: emittedCandidates, + emittedValueDuffs: diagnosticSaturatingSum(emittedCandidates.map(\.txo.amount)), + emittedBip44Count: emittedBip44.count, + emittedBip44ValueDuffs: diagnosticSaturatingSum(emittedBip44.map(\.txo.amount)), + emittedCoinJoinCount: emittedCoinJoin.count, + emittedCoinJoinValueDuffs: diagnosticSaturatingSum( + emittedCoinJoin.map(\.txo.amount) + ), + missingAccountCount: candidates.filter { + $0.rejectionReason == .missingAccount + }.count, + invalidTxidCount: candidates.filter { + $0.rejectionReason == .invalidTxid + }.count, + invalidAccountTypeCount: candidates.filter { + $0.rejectionReason == .invalidAccountType + }.count + ) + } + + struct DatabaseTxoAuditRow: Sendable { + let txo: CoreWalletDatabaseDiagnosticSnapshot.Txo + let hasParentTransaction: Bool + let walletIdMismatch: Bool + let isSpent: Bool + let hasSpendingTransaction: Bool + } + + struct DatabaseTxoAnomaly: Sendable { + let txo: CoreWalletDatabaseDiagnosticSnapshot.Txo + let reason: String + } + + struct DatabaseTxoAnomalyResult: Sendable { + let details: [DatabaseTxoAnomaly] + let emittedDetails: [DatabaseTxoAnomaly] + let truncatedCount: Int + + func count(reason: String) -> Int { + details.filter { $0.reason == reason }.count + } + } + + static func databaseTxoAnomalies( + _ rows: [DatabaseTxoAuditRow] + ) -> DatabaseTxoAnomalyResult { + var details: [DatabaseTxoAnomaly] = [] + for row in rows { + if row.txo.account == nil { + details.append(.init(txo: row.txo, reason: "missing_account")) + } + if !row.hasParentTransaction { + details.append(.init(txo: row.txo, reason: "missing_parent_transaction")) + } + if row.walletIdMismatch { + details.append(.init(txo: row.txo, reason: "wallet_id_mismatch")) + } + if row.isSpent && !row.hasSpendingTransaction { + details.append(.init( + txo: row.txo, + reason: "spent_without_spending_transaction" + )) + } + if !row.isSpent && row.hasSpendingTransaction { + details.append(.init( + txo: row.txo, + reason: "unspent_with_spending_transaction" + )) + } + if row.txo.outpoint.count != 36 { + details.append(.init(txo: row.txo, reason: "invalid_outpoint_length")) + } + if row.txo.scriptPubKey.isEmpty { + details.append(.init(txo: row.txo, reason: "empty_script_pubkey")) + } + } + details.sort { + if $0.reason != $1.reason { return $0.reason < $1.reason } + return $0.txo.outpoint.lexicographicallyPrecedes($1.txo.outpoint) + } + let grouped = Dictionary(grouping: details, by: \.reason) + var emitted: [DatabaseTxoAnomaly] = [] + var truncated = 0 + for reason in grouped.keys.sorted() { + let rows = grouped[reason] ?? [] + emitted.append(contentsOf: rows.prefix(CoreDiagnosticConstants.detailLimit)) + truncated += max(0, rows.count - CoreDiagnosticConstants.detailLimit) + } + return .init(details: details, emittedDetails: emitted, truncatedCount: truncated) + } + + struct ShieldedNote: Sendable { + let value: UInt64 + let isSpent: Bool + } + + struct ShieldedStoreSummary: Sendable { + let noteCount: Int + let spentNoteCount: Int + let spentValueCredits: UInt64 + let unspentNoteCount: Int + let unspentValueCredits: UInt64 + let outgoingNoteCount: Int + let activityCount: Int + let activityPendingCount: Int + let activityFailedCount: Int + let viewingKeyCount: Int + let subwalletSyncStateCount: Int + let maximumSyncWatermark: UInt64 + } + + static func summarizeShieldedStore( + notes: [ShieldedNote], + outgoingNoteCount: Int, + activityStatuses: [Int], + viewingKeyCount: Int, + syncWatermarks: [UInt64] + ) -> ShieldedStoreSummary { + let spent = notes.filter(\.isSpent) + let unspent = notes.filter { !$0.isSpent } + return ShieldedStoreSummary( + noteCount: notes.count, + spentNoteCount: spent.count, + spentValueCredits: diagnosticSaturatingSum(spent.map(\.value)), + unspentNoteCount: unspent.count, + unspentValueCredits: diagnosticSaturatingSum(unspent.map(\.value)), + outgoingNoteCount: outgoingNoteCount, + activityCount: activityStatuses.count, + activityPendingCount: activityStatuses.filter { $0 == 0 }.count, + activityFailedCount: activityStatuses.filter { $0 == 2 }.count, + viewingKeyCount: viewingKeyCount, + subwalletSyncStateCount: syncWatermarks.count, + maximumSyncWatermark: syncWatermarks.max() ?? 0 + ) + } + + private static func limitedTxoDetails( + _ details: [TxoDiffDetail] + ) -> (emitted: [TxoDiffDetail], truncated: Int) { + let grouped = Dictionary(grouping: details, by: \.reason) + var emitted: [TxoDiffDetail] = [] + var truncated = 0 + for reason in grouped.keys.sorted() { + let rows = (grouped[reason] ?? []).sorted(by: txoDetailOrder) + emitted.append(contentsOf: rows.prefix(CoreDiagnosticConstants.detailLimit)) + truncated += max(0, rows.count - CoreDiagnosticConstants.detailLimit) + } + return (emitted, truncated) + } + + private static func limitedAssetLockDetails( + _ details: [AssetLockDiffDetail] + ) -> (emitted: [AssetLockDiffDetail], truncated: Int) { + let grouped = Dictionary(grouping: details, by: \.reason) + var emitted: [AssetLockDiffDetail] = [] + var truncated = 0 + for reason in grouped.keys.sorted() { + let rows = (grouped[reason] ?? []).sorted(by: assetLockDetailOrder) + emitted.append(contentsOf: rows.prefix(CoreDiagnosticConstants.detailLimit)) + truncated += max(0, rows.count - CoreDiagnosticConstants.detailLimit) + } + return (emitted, truncated) + } + + private static func txoOrder( + _ lhs: CoreWalletDatabaseDiagnosticSnapshot.Txo, + _ rhs: CoreWalletDatabaseDiagnosticSnapshot.Txo + ) -> Bool { + if lhs.outpoint != rhs.outpoint { + return lhs.outpoint.lexicographicallyPrecedes(rhs.outpoint) + } + if lhs.amount != rhs.amount { return lhs.amount < rhs.amount } + if lhs.height != rhs.height { return lhs.height < rhs.height } + if lhs.scriptPubKey != rhs.scriptPubKey { + return lhs.scriptPubKey.lexicographicallyPrecedes(rhs.scriptPubKey) + } + if lhs.isLocked != rhs.isLocked { return !lhs.isLocked && rhs.isLocked } + let lhsAccount = lhs.account?.referenceMaterial ?? Data() + let rhsAccount = rhs.account?.referenceMaterial ?? Data() + return lhsAccount.lexicographicallyPrecedes(rhsAccount) + } + + private static func txoDetailOrder(_ lhs: TxoDiffDetail, _ rhs: TxoDiffDetail) -> Bool { + if lhs.reason != rhs.reason { return lhs.reason < rhs.reason } + return lhs.outpoint.lexicographicallyPrecedes(rhs.outpoint) + } + + private static func assetLockOrder( + _ lhs: CoreWalletDatabaseDiagnosticSnapshot.AssetLock, + _ rhs: CoreWalletDatabaseDiagnosticSnapshot.AssetLock + ) -> Bool { + lhs.outpointDisplay < rhs.outpointDisplay + } + + private static func assetLockDetailOrder( + _ lhs: AssetLockDiffDetail, + _ rhs: AssetLockDiffDetail + ) -> Bool { + if lhs.reason != rhs.reason { return lhs.reason < rhs.reason } + return lhs.outpointDisplay < rhs.outpointDisplay + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 8b7025e72b3..5e28fc5dc4a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -491,6 +491,17 @@ public class PlatformWalletManager: ObservableObject { } } + /// Diagnostics use the same admission/drain contract as other background + /// native work: once admitted, shutdown cannot consume the manager handle + /// until the read-only snapshot has finished on `destroyQueue`. + func admitCoreDiagnosticsNativeOp() throws { + try admitNativeOp("coreWalletDiagnostics") + } + + func finishCoreDiagnosticsNativeOp() { + finishNativeOp() + } + /// Test seam for the individual native calls. Production keeps `.live`; /// tests replace the function table while still running the production /// teardown orchestration end-to-end. @@ -1287,6 +1298,8 @@ public class PlatformWalletManager: ObservableObject { /// `createWallet` flow. @discardableResult public func loadFromPersistor() throws -> [ManagedPlatformWallet] { + let diagnosticPersistenceHandler = persistenceHandler + defer { diagnosticPersistenceHandler?.clearStartupCoreDiagnosticSnapshots() } // Same synchronous-admission gate as the sync creates: rejected // during the shutdown drain AND while an async native op is in // flight — a second Rust loader running concurrently with the one @@ -1369,6 +1382,13 @@ public class PlatformWalletManager: ObservableObject { } } + for managedWallet in restored { + emitCoreWalletDiagnosticsSynchronously( + for: managedWallet.walletId, + checkpoint: .startupPostRestore + ) + } + // Kick off a background catch-up pass for every persisted // asset lock at `statusRaw < 2`. Closes the SPV-restart gap: // the wallet's in-memory transactions map was just @@ -1510,12 +1530,13 @@ public class PlatformWalletManager: ObservableObject { /// and once admitted the teardown waits for the full transaction. @discardableResult public func loadFromPersistor() async throws -> [ManagedPlatformWallet] { + let handler = persistenceHandler + defer { handler?.clearStartupCoreDiagnosticSnapshots() } try ensureConfigured() try admitNativeOp("loadFromPersistor") defer { finishNativeOp() } let h = handle - let handler = persistenceHandler let calls = nativeLoadCalls // Direct continuation for the same FIFO reason as the async @@ -1588,6 +1609,13 @@ public class PlatformWalletManager: ObservableObject { ] ) + for managedWallet in restored { + await emitCoreWalletDiagnostics( + for: managedWallet.walletId, + checkpoint: .startupPostRestore + ) + } + catchUpStuckAssetLocks(wallets: restored) return restored } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift new file mode 100644 index 00000000000..4ff27a976bf --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerCoreDiagnostics.swift @@ -0,0 +1,1551 @@ +import CryptoKit +import DashSDKFFI +import Foundation +import SwiftData + +/// Named checkpoints make two exports from the same device directly +/// comparable without putting any user-controlled text in the log. +enum CoreWalletDiagnosticCheckpoint: String, Sendable { + case startupPreRestore = "startup_pre_restore" + case startupPostRestore = "startup_post_restore" + case preExport = "pre_export" +} + +/// Value-only copy of the SwiftData state used after the handler has released +/// its serial queue. No SwiftData model object crosses the queue boundary. +struct CoreWalletDatabaseDiagnosticSnapshot: Sendable { + struct AccountKey: Hashable, Sendable { + let typeTag: UInt32 + let standardTag: UInt8 + let index: UInt32 + let registrationIndex: UInt32 + let keyClass: UInt32 + let userIdentityId: Data + let friendIdentityId: Data + + init( + typeTag: UInt32, + standardTag: UInt8, + index: UInt32, + registrationIndex: UInt32, + keyClass: UInt32, + userIdentityId: Data, + friendIdentityId: Data + ) { + self.typeTag = typeTag + self.standardTag = standardTag + self.index = index + self.registrationIndex = registrationIndex + self.keyClass = keyClass + self.userIdentityId = Self.ffiIdentityBytes(userIdentityId) + self.friendIdentityId = Self.ffiIdentityBytes(friendIdentityId) + } + + var referenceMaterial: Data { + var data = Data() + data.appendLittleEndian(typeTag) + data.append(standardTag) + data.appendLittleEndian(index) + data.appendLittleEndian(registrationIndex) + data.appendLittleEndian(keyClass) + data.append(userIdentityId) + data.append(friendIdentityId) + return data + } + + private static func ffiIdentityBytes(_ value: Data) -> Data { + if value.count == 32 { return value } + if value.count > 32 { return Data(value.prefix(32)) } + var padded = Data(value) + padded.append(Data(repeating: 0, count: 32 - value.count)) + return padded + } + } + + struct Txo: Sendable { + let outpoint: Data + let amount: UInt64 + let height: UInt32 + let scriptPubKey: Data + let isLocked: Bool + let account: AccountKey? + } + + struct AssetLock: Sendable { + let outpointDisplay: String + let fundingType: Int + let status: Int + let accountIndex: UInt32 + let registrationIndex: UInt32 + /// `nil` represents a corrupt negative value in the signed legacy + /// SwiftData column; a valid in-memory `UInt64` can never equal it. + let amountDuffs: UInt64? + let hasProof: Bool + } + + let walletId: Data + let accounts: [AccountKey] + let unspentTxos: [Txo] + let assetLocks: [AssetLock] + let assetLocksAvailable: Bool +} + +enum CoreDiagnosticConstants { + static let detailLimit = 25 +} + +private extension Data { + mutating func appendLittleEndian(_ value: T) { + var littleEndian = value.littleEndian + Swift.withUnsafeBytes(of: &littleEndian) { append(contentsOf: $0) } + } +} + +func diagnosticSaturatingSum(_ values: S) -> UInt64 +where S.Element == UInt64 { + values.reduce(0) { partial, value in + let (sum, overflow) = partial.addingReportingOverflow(value) + return overflow ? UInt64.max : sum + } +} + +private func diagnosticSignedSaturatingSum(_ values: S) -> Int64 +where S.Element == Int64 { + values.reduce(0) { partial, value in + let (sum, overflow) = partial.addingReportingOverflow(value) + if !overflow { return sum } + return value >= 0 ? Int64.max : Int64.min + } +} + +func diagnosticFingerprint(_ records: [Data]) -> Data { + var hasher = SHA256() + for record in records.sorted(by: { $0.lexicographicallyPrecedes($1) }) { + var length = UInt64(record.count).littleEndian + Swift.withUnsafeBytes(of: &length) { hasher.update(bufferPointer: $0) } + hasher.update(data: record) + } + return Data(hasher.finalize()) +} + +func diagnosticTxoFingerprint( + outpoint: Data, + amount: UInt64, + height: UInt32, + scriptPubKey: Data, + isLocked: Bool, + account: CoreWalletDatabaseDiagnosticSnapshot.AccountKey? +) -> Data { + var data = Data() + data.appendLittleEndian(UInt64(outpoint.count)) + data.append(outpoint) + data.appendLittleEndian(amount) + data.appendLittleEndian(height) + data.append(isLocked ? 1 : 0) + data.appendLittleEndian(UInt64(scriptPubKey.count)) + data.append(scriptPubKey) + if let account { + data.append(1) + data.appendLittleEndian(UInt64(account.referenceMaterial.count)) + data.append(account.referenceMaterial) + } else { + data.append(0) + } + return data +} + +/// Canonical material for one exact `UtxoRestoreEntryFFI` row. The general +/// DB↔memory UTXO query cannot observe these three flags, so they live only in +/// this restore-specific fingerprint instead of creating false memory diffs. +func diagnosticRestoreTxoFingerprint( + _ candidate: CoreWalletDiagnosticAnalyzer.RestoreCandidate +) -> Data { + var data = diagnosticTxoFingerprint( + outpoint: candidate.txo.outpoint, + amount: candidate.txo.amount, + height: candidate.txo.height, + scriptPubKey: candidate.txo.scriptPubKey, + isLocked: candidate.txo.isLocked, + account: candidate.txo.account + ) + data.append(candidate.isCoinbase ? 1 : 0) + data.append(candidate.isConfirmed ? 1 : 0) + data.append(candidate.isInstantLocked ? 1 : 0) + return data +} + +extension PlatformWalletPersistenceHandler { + /// Main-actor-friendly entry point used by manual log export. The handler's + /// serial queue owns the ModelContext; only a Sendable value snapshot is + /// resumed across the continuation. + func emitCoreWalletDatabaseDiagnostics( + walletId: Data, + checkpoint: CoreWalletDiagnosticCheckpoint + ) async -> CoreWalletDatabaseDiagnosticSnapshot? { + await withCheckedContinuation { continuation in + serialQueue.async { [self] in + let snapshot = autoreleasepool { () -> CoreWalletDatabaseDiagnosticSnapshot? in + if checkpoint == .startupPostRestore, + let cached = startupCoreDiagnosticSnapshots.removeValue(forKey: walletId) { + SDKLogger.event( + "core_db_startup_snapshot_reused", + category: .persistence, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "wallet_reference": .reference(walletId), + ] + ) + return cached + } + return emitCoreWalletDatabaseDiagnosticsOnQueue( + walletId: walletId, + checkpoint: checkpoint + ) + } + continuation.resume(returning: snapshot) + } + } + } + + /// Synchronous companion for the legacy synchronous restore overload. + func emitCoreWalletDatabaseDiagnostics( + walletId: Data, + checkpoint: CoreWalletDiagnosticCheckpoint + ) -> CoreWalletDatabaseDiagnosticSnapshot? { + onQueue { + if checkpoint == .startupPostRestore, + let cached = startupCoreDiagnosticSnapshots.removeValue(forKey: walletId) { + SDKLogger.event( + "core_db_startup_snapshot_reused", + category: .persistence, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "wallet_reference": .reference(walletId), + ] + ) + return cached + } + return emitCoreWalletDatabaseDiagnosticsOnQueue( + walletId: walletId, + checkpoint: checkpoint + ) + } + } + + /// Must be called while `serialQueue` is held. `loadWalletList` uses this + /// directly, avoiding a recursive `serialQueue.sync` deadlock. + @discardableResult + func emitCoreWalletDatabaseDiagnosticsOnQueue( + walletId: Data, + checkpoint: CoreWalletDiagnosticCheckpoint + ) -> CoreWalletDatabaseDiagnosticSnapshot? { + // A previous restore can fail after the pre-snapshot was cached but + // before post-restore consumes it. Never let a later attempt compare + // Rust against that stale value. + if checkpoint == .startupPreRestore { + startupCoreDiagnosticSnapshots.removeValue(forKey: walletId) + } + do { + let walletDescriptor = FetchDescriptor( + predicate: PersistentWallet.predicate(walletId: walletId) + ) + guard let wallet = try backgroundContext.fetch(walletDescriptor).first else { + SDKLogger.event( + "core_diagnostics_unavailable", + category: .persistence, + severity: .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "reason": .publicText("wallet_not_found"), + "wallet_reference": .reference(walletId), + ] + ) + return nil + } + + let allTxos = try backgroundContext.fetch(FetchDescriptor()) + let walletTxos = allTxos.filter { + $0.walletId == walletId || Self.relationshipWalletId(of: $0) == walletId + } + // Walking every transaction relationship is deliberately export-only. + // A heavily mixed wallet can have enough history for this traversal to + // stall restore, which is precisely the failure this instrumentation is + // intended to diagnose rather than reproduce. + let allTransactions: [PersistentTransaction]? + let walletTransactions: [PersistentTransaction]? + if checkpoint == .preExport { + do { + let fetched = try backgroundContext.fetch( + FetchDescriptor() + ) + allTransactions = fetched + walletTransactions = fetched.filter { + Self.walletOwnsTransaction(walletId: walletId, transaction: $0) + } + } catch { + allTransactions = nil + walletTransactions = nil + SDKLogger.event( + "core_owned_output_audit_summary", + category: .persistence, + severity: .warning, + fields: [ + "audit_incomplete": .boolean(true), + "checkpoint": .publicText(checkpoint.rawValue), + "reason": .publicText("transaction_fetch_failed"), + "wallet_reference": .reference(walletId), + ] + ) + } + } else { + allTransactions = nil + walletTransactions = nil + } + let pending: [PersistentPendingInput]? + do { + pending = try backgroundContext.fetch( + FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + ) + ) + } catch { + pending = nil + } + + let confirmed = walletTxos.filter(\.isConfirmed) + let unconfirmed = walletTxos.filter { !$0.isConfirmed } + let spent = walletTxos.filter(\.isSpent) + let unspent = walletTxos.filter { !$0.isSpent } + let locked = walletTxos.filter(\.isLocked) + let txoFingerprint = diagnosticFingerprint(walletTxos.map { + diagnosticTxoFingerprint( + outpoint: $0.outpoint, + amount: $0.amount, + height: $0.height, + scriptPubKey: $0.scriptPubKey, + isLocked: $0.isLocked, + account: Self.diagnosticAccountKey($0.account) + ) + }) + let now = Date() + let oldestPendingAge: Int64 + if let pending { + oldestPendingAge = pending.compactMap { row -> Int64? in + let interval = now.timeIntervalSince(row.createdAt) + guard interval.isFinite else { return nil } + if interval <= 0 { return 0 } + if interval >= Double(Int64.max) { return Int64.max } + return Int64(interval) + }.max() ?? 0 + } else { + oldestPendingAge = -1 + } + + SDKLogger.event( + "core_db_wallet_snapshot", + category: .persistence, + fields: [ + "account_count": .integer(Int64(wallet.accounts.count)), + "birth_height": .unsignedInteger(UInt64(wallet.birthHeight)), + "checkpoint": .publicText(checkpoint.rawValue), + "confirmed_count": .integer(Int64(confirmed.count)), + "confirmed_value_duffs": .unsignedInteger( + diagnosticSaturatingSum(confirmed.map(\.amount)) + ), + "locked_count": .integer(Int64(locked.count)), + "locked_value_duffs": .unsignedInteger( + diagnosticSaturatingSum(locked.map(\.amount)) + ), + "oldest_pending_input_age_seconds": .integer(oldestPendingAge), + "pending_input_count": .integer(pending.map { Int64($0.count) } ?? -1), + "pending_query_available": .boolean(pending != nil), + "spent_count": .integer(Int64(spent.count)), + "spent_value_duffs": .unsignedInteger( + diagnosticSaturatingSum(spent.map(\.amount)) + ), + "synced_height": .unsignedInteger(UInt64(wallet.syncedHeight)), + "transaction_count": .integer( + walletTransactions.map { Int64($0.count) } ?? -1 + ), + "transaction_scan_available": .boolean(walletTransactions != nil), + "txo_count": .integer(Int64(walletTxos.count)), + "txo_fingerprint": .reference(txoFingerprint), + "unconfirmed_count": .integer(Int64(unconfirmed.count)), + "unconfirmed_value_duffs": .unsignedInteger( + diagnosticSaturatingSum(unconfirmed.map(\.amount)) + ), + "unspent_count": .integer(Int64(unspent.count)), + "unspent_value_duffs": .unsignedInteger( + diagnosticSaturatingSum(unspent.map(\.amount)) + ), + "wallet_reference": .reference(walletId), + ] + ) + + let sortedAccounts = wallet.accounts.sorted { + ($0.accountType, $0.standardTag, $0.accountIndex, + $0.registrationIndex, $0.keyClass) + < ($1.accountType, $1.standardTag, $1.accountIndex, + $1.registrationIndex, $1.keyClass) + } + for account in sortedAccounts { + let key = Self.diagnosticAccountKey(account)! + let accountTxos = walletTxos.filter { $0.account === account } + let accountSpent = accountTxos.filter(\.isSpent) + let accountUnspent = accountTxos.filter { !$0.isSpent } + let accountConfirmed = accountTxos.filter(\.isConfirmed) + let accountUnconfirmed = accountTxos.filter { !$0.isConfirmed } + let accountLocked = accountTxos.filter(\.isLocked) + let externalAddresses = account.coreAddresses.filter { $0.poolTypeTag == 0 } + let internalAddresses = account.coreAddresses.filter { $0.poolTypeTag == 1 } + let accountFingerprint = diagnosticFingerprint(accountTxos.map { + diagnosticTxoFingerprint( + outpoint: $0.outpoint, + amount: $0.amount, + height: $0.height, + scriptPubKey: $0.scriptPubKey, + isLocked: $0.isLocked, + account: key + ) + }) + SDKLogger.event( + "core_db_account_snapshot", + category: .persistence, + fields: [ + "account_index": .unsignedInteger(UInt64(account.accountIndex)), + "account_reference": .reference(key.referenceMaterial), + "account_type": .unsignedInteger(UInt64(account.accountType)), + "checkpoint": .publicText(checkpoint.rawValue), + "confirmed_count": .integer(Int64(accountConfirmed.count)), + "confirmed_value_duffs": .unsignedInteger( + diagnosticSaturatingSum(accountConfirmed.map(\.amount)) + ), + "external_address_count": .integer(Int64(externalAddresses.count)), + "external_highest_used": .integer(Int64(account.externalHighestUsed)), + "internal_address_count": .integer(Int64(internalAddresses.count)), + "internal_highest_used": .integer(Int64(account.internalHighestUsed)), + "locked_count": .integer(Int64(accountLocked.count)), + "locked_value_duffs": .unsignedInteger( + diagnosticSaturatingSum(accountLocked.map(\.amount)) + ), + "registration_index": .unsignedInteger(UInt64(account.registrationIndex)), + "spent_count": .integer(Int64(accountSpent.count)), + "spent_value_duffs": .unsignedInteger( + diagnosticSaturatingSum(accountSpent.map(\.amount)) + ), + "standard_tag": .unsignedInteger(UInt64(account.standardTag)), + "txo_fingerprint": .reference(accountFingerprint), + "unconfirmed_count": .integer(Int64(accountUnconfirmed.count)), + "unconfirmed_value_duffs": .unsignedInteger( + diagnosticSaturatingSum(accountUnconfirmed.map(\.amount)) + ), + "unspent_count": .integer(Int64(accountUnspent.count)), + "unspent_value_duffs": .unsignedInteger( + diagnosticSaturatingSum(accountUnspent.map(\.amount)) + ), + "used_address_count": .integer( + Int64(account.coreAddresses.filter(\.isUsed).count) + ), + "wallet_reference": .reference(walletId), + ] + ) + } + + Self.logTxoAnomalies( + walletId: walletId, + checkpoint: checkpoint, + txos: walletTxos + ) + // Decoding a heavily mixed wallet's full transaction history can + // be expensive. The exact #4438 audit is needed for the manually + // exported artifact, not for restoring Rust, so keep startup's + // persistence queue limited to lightweight summaries. + if checkpoint == .preExport, + let allTransactions { + Self.auditCoinJoinOwnedBip44Outputs( + wallet: wallet, + walletId: walletId, + checkpoint: checkpoint, + allTxos: allTxos, + allTransactions: allTransactions + ) + } + + let assetLocks: [CoreWalletDatabaseDiagnosticSnapshot.AssetLock] + let assetLocksAvailable: Bool + do { + assetLocks = try Self.logAssetLockDatabaseSnapshot( + context: backgroundContext, + walletId: walletId, + checkpoint: checkpoint, + walletTransactions: walletTransactions + ) + assetLocksAvailable = true + } catch { + assetLocks = [] + assetLocksAvailable = false + SDKLogger.event( + "asset_lock_db_snapshot", + category: .persistence, + severity: .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "query_available": .boolean(false), + "wallet_reference": .reference(walletId), + ] + ) + } + do { + try Self.logShieldedStoreSnapshot( + context: backgroundContext, + walletId: walletId, + checkpoint: checkpoint + ) + } catch { + SDKLogger.event( + "shielded_store_snapshot", + category: .shielded, + severity: .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "query_available": .boolean(false), + "wallet_reference": .reference(walletId), + ] + ) + } + + let snapshot = CoreWalletDatabaseDiagnosticSnapshot( + walletId: walletId, + accounts: sortedAccounts.compactMap(Self.diagnosticAccountKey), + unspentTxos: unspent.map { + CoreWalletDatabaseDiagnosticSnapshot.Txo( + outpoint: $0.outpoint, + amount: $0.amount, + height: $0.height, + scriptPubKey: $0.scriptPubKey, + isLocked: $0.isLocked, + account: Self.diagnosticAccountKey($0.account) + ) + }, + assetLocks: assetLocks, + assetLocksAvailable: assetLocksAvailable + ) + if checkpoint == .startupPreRestore { + startupCoreDiagnosticSnapshots[walletId] = snapshot + } + return snapshot + } catch { + SDKLogger.event( + "core_diagnostics_unavailable", + category: .persistence, + severity: .error, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "reason": .publicText("swiftdata_fetch_failed"), + "wallet_reference": .reference(walletId), + ] + ) + return nil + } + } + + /// Logs the exact UTXO slice handed to Rust, independently of the broader + /// database snapshot. This sits after compact-write, so `emitted_count` + /// cannot be confused with the number of fetched candidates. + func logCoreRestoreBufferSnapshotOnQueue( + walletId: Data, + rows: [PersistentTxo], + emittedCount: Int, + errored: Bool + ) { + let candidates = rows.map { row in + let rejection: CoreWalletDiagnosticAnalyzer.RestoreCandidate.RejectionReason? + if row.account == nil { + rejection = .missingAccount + } else if row.txid.count != 32 { + rejection = .invalidTxid + } else if let account = row.account, + UInt8(exactly: account.accountType) == nil { + rejection = .invalidAccountType + } else { + rejection = nil + } + return CoreWalletDiagnosticAnalyzer.RestoreCandidate( + txo: CoreWalletDatabaseDiagnosticSnapshot.Txo( + outpoint: PersistentTxo.makeOutpoint(txid: row.txid, vout: row.vout), + amount: row.amount, + height: row.height, + scriptPubKey: row.scriptPubKey, + isLocked: row.isLocked, + account: Self.diagnosticAccountKey(row.account) + ), + accountType: row.account?.accountType, + standardTag: row.account?.standardTag, + rejectionReason: rejection, + isCoinbase: row.isCoinbase, + isConfirmed: row.isConfirmed, + isInstantLocked: row.isInstantLocked + ) + } + // A validation error deallocates the compact buffer and aborts the + // whole callback, so zero rows were actually handed to Rust even if + // some valid rows preceded the corrupt one. + let summary = CoreWalletDiagnosticAnalyzer.summarizeRestoreBuffer( + candidates: candidates, + emittedCount: emittedCount, + errored: errored + ) + let emittedMaterials = summary.emittedCandidates.map(diagnosticRestoreTxoFingerprint) + let hasRejectedRows = summary.missingAccountCount > 0 + || summary.invalidTxidCount > 0 + || summary.invalidAccountTypeCount > 0 + + SDKLogger.event( + "core_restore_buffer_snapshot", + category: .persistence, + severity: errored ? .error : (hasRejectedRows ? .warning : .info), + fields: [ + "candidate_count": .integer(Int64(summary.candidateCount)), + "candidate_bip44_count": .integer(Int64(summary.candidateBip44Count)), + "candidate_bip44_value_duffs": .unsignedInteger( + summary.candidateBip44ValueDuffs + ), + "candidate_coinjoin_count": .integer(Int64(summary.candidateCoinJoinCount)), + "candidate_coinjoin_value_duffs": .unsignedInteger( + summary.candidateCoinJoinValueDuffs + ), + "candidate_value_duffs": .unsignedInteger(summary.candidateValueDuffs), + "built_count": .integer(Int64(summary.builtCount)), + "checkpoint": .publicText(CoreWalletDiagnosticCheckpoint.startupPreRestore.rawValue), + "emitted_count": .integer(Int64(summary.emittedCandidates.count)), + "emitted_bip44_count": .integer(Int64(summary.emittedBip44Count)), + "emitted_bip44_value_duffs": .unsignedInteger( + summary.emittedBip44ValueDuffs + ), + "emitted_coinjoin_count": .integer(Int64(summary.emittedCoinJoinCount)), + "emitted_coinjoin_value_duffs": .unsignedInteger( + summary.emittedCoinJoinValueDuffs + ), + "emitted_fingerprint": .reference(diagnosticFingerprint(emittedMaterials)), + "emitted_value_duffs": .unsignedInteger(summary.emittedValueDuffs), + "errored": .boolean(errored), + "skipped_invalid_account_type_count": .integer( + Int64(summary.invalidAccountTypeCount) + ), + "skipped_invalid_txid_count": .integer(Int64(summary.invalidTxidCount)), + "skipped_missing_account_count": .integer(Int64(summary.missingAccountCount)), + "wallet_reference": .reference(walletId), + ] + ) + } + + private static func diagnosticAccountKey( + _ account: PersistentAccount? + ) -> CoreWalletDatabaseDiagnosticSnapshot.AccountKey? { + guard let account else { return nil } + return CoreWalletDatabaseDiagnosticSnapshot.AccountKey( + typeTag: account.accountType, + standardTag: account.standardTag, + index: account.accountIndex, + registrationIndex: account.registrationIndex, + keyClass: account.keyClass, + userIdentityId: account.userIdentityId, + friendIdentityId: account.friendIdentityId + ) + } + + /// Read the relationship-owned wallet independently of the denormalized + /// `PersistentTxo.walletId`. Diagnostics must compare the two sources; + /// `resolvedWalletId(of:)` deliberately prefers the denormalized value and + /// would therefore hide exactly the corruption we are trying to expose. + private static func relationshipWalletId(of txo: PersistentTxo) -> Data? { + let account: PersistentAccount? = txo.account + guard let account else { return nil } + let wallet: PersistentWallet? = account.wallet + return wallet?.walletId + } + + private static func logTxoAnomalies( + walletId: Data, + checkpoint: CoreWalletDiagnosticCheckpoint, + txos: [PersistentTxo] + ) { + let result = CoreWalletDiagnosticAnalyzer.databaseTxoAnomalies(txos.map { txo in + let relationshipWalletId = relationshipWalletId(of: txo) + return CoreWalletDiagnosticAnalyzer.DatabaseTxoAuditRow( + txo: CoreWalletDatabaseDiagnosticSnapshot.Txo( + outpoint: txo.outpoint, + amount: txo.amount, + height: txo.height, + scriptPubKey: txo.scriptPubKey, + isLocked: txo.isLocked, + account: diagnosticAccountKey(txo.account) + ), + hasParentTransaction: txo.transaction != nil, + walletIdMismatch: !txo.walletId.isEmpty + && relationshipWalletId != nil + && txo.walletId != relationshipWalletId, + isSpent: txo.isSpent, + hasSpendingTransaction: txo.spendingTransaction != nil + ) + }) + SDKLogger.event( + "core_db_anomaly_summary", + category: .persistence, + severity: result.details.isEmpty ? .info : .warning, + fields: [ + "anomaly_count": .integer(Int64(result.details.count)), + "checkpoint": .publicText(checkpoint.rawValue), + "detail_count": .integer(Int64(result.emittedDetails.count)), + "empty_script_count": .integer(Int64(result.count(reason: "empty_script_pubkey"))), + "invalid_outpoint_count": .integer(Int64( + result.count(reason: "invalid_outpoint_length") + )), + "missing_account_count": .integer(Int64(result.count(reason: "missing_account"))), + "missing_parent_transaction_count": .integer(Int64( + result.count(reason: "missing_parent_transaction") + )), + "spent_relation_mismatch_count": .integer(Int64( + result.count(reason: "spent_without_spending_transaction") + + result.count(reason: "unspent_with_spending_transaction") + )), + "truncated_count": .integer(Int64(result.truncatedCount)), + "wallet_mismatch_count": .integer(Int64( + result.count(reason: "wallet_id_mismatch") + )), + "wallet_reference": .reference(walletId), + ] + ) + for detail in result.emittedDetails { + SDKLogger.event( + "core_db_txo_anomaly", + category: .persistence, + severity: .warning, + fields: [ + "amount_duffs": .unsignedInteger(detail.txo.amount), + "checkpoint": .publicText(checkpoint.rawValue), + "height": .unsignedInteger(UInt64(detail.txo.height)), + "outpoint_reference": .reference(detail.txo.outpoint), + "reason": .publicText(detail.reason), + "wallet_reference": .reference(walletId), + ] + ) + } + } + + /// Exact detector for dashpay/platform#4438. It does not trust the + /// transaction's persisted role: it decodes inputs, proves at least one + /// spends a known CoinJoin TXO, then checks every decoded output against + /// the persisted BIP44 address pool and the TXO table. + private static func auditCoinJoinOwnedBip44Outputs( + wallet: PersistentWallet, + walletId: Data, + checkpoint: CoreWalletDiagnosticCheckpoint, + allTxos: [PersistentTxo], + allTransactions: [PersistentTransaction] + ) { + let coinJoinOutpoints = Set(allTxos.compactMap { txo -> Data? in + guard relationshipWalletId(of: txo) == walletId, + txo.account?.accountType == 1 + else { return nil } + return txo.outpoint + }) + var bip44Addresses: [String: PersistentAccount] = [:] + for account in wallet.accounts where account.accountType == 0 && account.standardTag == 0 { + for coreAddress in account.coreAddresses where bip44Addresses[coreAddress.address] == nil { + bip44Addresses[coreAddress.address] = account + } + } + let txoByOutpoint = Dictionary(grouping: allTxos, by: \.outpoint) + + var candidateCount = 0 + var decodeFailureCount = 0 + var ownedOutputCount = 0 + var ownedOutputValue: UInt64 = 0 + var validCount = 0 + var anomalies: [(tx: PersistentTransaction, vout: UInt32, amount: UInt64, + outpoint: Data, reason: String)] = [] + + guard let network = wallet.network else { + SDKLogger.event( + "core_owned_output_audit_summary", + category: .persistence, + severity: .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "reason": .publicText("wallet_network_unknown"), + "wallet_reference": .reference(walletId), + ] + ) + return + } + + for transaction in allTransactions where !transaction.transactionData.isEmpty { + let decoded: DecodedTransaction + do { + decoded = try TransactionDecoder.decode(transaction.transactionData, network: network) + } catch { + // Only count decode failures for rows already associated with + // this wallet; unrelated-wallet corruption must not pollute + // this wallet's audit result. + if walletOwnsTransaction(walletId: walletId, transaction: transaction) { + decodeFailureCount += 1 + } + continue + } + let spendsCoinJoin = decoded.inputs.contains { input in + coinJoinOutpoints.contains( + PersistentTxo.makeOutpoint(txid: input.prevTxid, vout: input.prevVout) + ) + } + guard spendsCoinJoin else { continue } + candidateCount += 1 + + for (index, output) in decoded.outputs.enumerated() { + guard let address = output.address, + let expectedAccount = bip44Addresses[address] + else { continue } + ownedOutputCount += 1 + let (newValue, overflow) = ownedOutputValue.addingReportingOverflow(output.valueDuffs) + ownedOutputValue = overflow ? UInt64.max : newValue + let vout = UInt32(index) + let outpoint = PersistentTxo.makeOutpoint(txid: decoded.txid, vout: vout) + guard let rows = txoByOutpoint[outpoint], let row = rows.first else { + anomalies.append((transaction, vout, output.valueDuffs, outpoint, "missing_txo")) + continue + } + guard relationshipWalletId(of: row) == walletId, + row.walletId.isEmpty || row.walletId == walletId + else { + anomalies.append((transaction, vout, output.valueDuffs, outpoint, "wrong_wallet")) + continue + } + guard row.account === expectedAccount, + row.account?.accountType == 0, + row.account?.standardTag == 0 + else { + anomalies.append((transaction, vout, output.valueDuffs, outpoint, "wrong_account")) + continue + } + guard row.amount == output.valueDuffs else { + anomalies.append((transaction, vout, output.valueDuffs, outpoint, "amount_mismatch")) + continue + } + guard row.scriptPubKey == output.scriptPubkey else { + anomalies.append((transaction, vout, output.valueDuffs, outpoint, "script_mismatch")) + continue + } + validCount += 1 + } + } + + anomalies.sort { + if $0.outpoint != $1.outpoint { + return $0.outpoint.lexicographicallyPrecedes($1.outpoint) + } + return $0.reason < $1.reason + } + let anomalyGroups = Dictionary(grouping: anomalies, by: { $0.reason }) + let truncatedAnomalyCount = anomalyGroups.values.reduce(0) { + $0 + max(0, $1.count - CoreDiagnosticConstants.detailLimit) + } + let missingCount = anomalies.filter { $0.reason == "missing_txo" }.count + let missingValue = diagnosticSaturatingSum(anomalies.compactMap { + $0.reason == "missing_txo" ? $0.amount : nil + }) + SDKLogger.event( + "core_owned_output_audit_summary", + category: .persistence, + severity: anomalies.isEmpty && decodeFailureCount == 0 ? .info : .warning, + fields: [ + "audit_incomplete": .boolean(decodeFailureCount > 0), + "candidate_transaction_count": .integer(Int64(candidateCount)), + "checkpoint": .publicText(checkpoint.rawValue), + "coinjoin_to_bip44_missing_count": .integer(Int64(missingCount)), + "coinjoin_to_bip44_missing_value_duffs": .unsignedInteger(missingValue), + "decode_failure_count": .integer(Int64(decodeFailureCount)), + "owned_bip44_output_count": .integer(Int64(ownedOutputCount)), + "owned_bip44_output_value_duffs": .unsignedInteger(ownedOutputValue), + "persisted_valid_count": .integer(Int64(validCount)), + "total_anomaly_count": .integer(Int64(anomalies.count)), + "truncated_count": .integer(Int64(truncatedAnomalyCount)), + "wallet_reference": .reference(walletId), + ] + ) + for reason in anomalyGroups.keys.sorted() { + for anomaly in (anomalyGroups[reason] ?? []).prefix(CoreDiagnosticConstants.detailLimit) { + SDKLogger.event( + "core_owned_output_anomaly", + category: .persistence, + severity: .warning, + fields: [ + "amount_duffs": .unsignedInteger(anomaly.amount), + "block_height": .unsignedInteger(UInt64(anomaly.tx.blockHeight)), + "checkpoint": .publicText(checkpoint.rawValue), + "input_account_kind": .publicText("coinjoin"), + "outpoint_reference": .reference(anomaly.outpoint), + "output_account_kind": .publicText("bip44"), + "reason": .publicText(reason), + "transaction_context": .unsignedInteger(UInt64(anomaly.tx.context)), + "transaction_reference": .reference(anomaly.tx.txid), + "vout": .unsignedInteger(UInt64(anomaly.vout)), + "wallet_reference": .reference(walletId), + ] + ) + } + } + } + + private static func logAssetLockDatabaseSnapshot( + context: ModelContext, + walletId: Data, + checkpoint: CoreWalletDiagnosticCheckpoint, + walletTransactions: [PersistentTransaction]? + ) throws -> [CoreWalletDatabaseDiagnosticSnapshot.AssetLock] { + let rows = try context.fetch( + FetchDescriptor( + predicate: PersistentAssetLock.predicate(walletId: walletId) + ) + ) + SDKLogger.event( + "asset_lock_db_snapshot", + category: .persistence, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "core_type_8_transaction_count": .integer( + walletTransactions.map { Int64($0.filter(\.isAssetLock).count) } ?? -1 + ), + "core_transaction_scan_available": .boolean(walletTransactions != nil), + "lock_count": .integer(Int64(rows.count)), + "proof_present_count": .integer(Int64(rows.filter { + $0.proofBytes?.isEmpty == false + }.count)), + "query_available": .boolean(true), + "shielded_funding_count": .integer(Int64(rows.filter { + $0.fundingTypeRaw == 5 + }.count)), + "transaction_bytes_present_count": .integer(Int64(rows.filter { + !$0.transactionBytes.isEmpty + }.count)), + "wallet_reference": .reference(walletId), + ] + ) + + let groups = Dictionary(grouping: rows) { + "\($0.fundingTypeRaw):\($0.statusRaw)" + } + for key in groups.keys.sorted() { + guard let group = groups[key], let first = group.first else { continue } + SDKLogger.event( + "asset_lock_db_group", + category: .persistence, + fields: [ + "amount_duffs": .integer( + diagnosticSignedSaturatingSum(group.map(\.amountDuffs)) + ), + "checkpoint": .publicText(checkpoint.rawValue), + "count": .integer(Int64(group.count)), + "funding_type": .integer(Int64(first.fundingTypeRaw)), + "status": .integer(Int64(first.statusRaw)), + "wallet_reference": .reference(walletId), + ] + ) + } + return rows.map { + CoreWalletDatabaseDiagnosticSnapshot.AssetLock( + outpointDisplay: $0.outPointHex, + fundingType: $0.fundingTypeRaw, + status: $0.statusRaw, + accountIndex: UInt32(bitPattern: $0.accountIndexRaw), + registrationIndex: UInt32(bitPattern: $0.identityIndexRaw), + amountDuffs: UInt64(exactly: $0.amountDuffs), + hasProof: $0.proofBytes?.isEmpty == false + ) + } + } + + private static func logShieldedStoreSnapshot( + context: ModelContext, + walletId: Data, + checkpoint: CoreWalletDiagnosticCheckpoint + ) throws { + let notes = try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + )) + let outgoing = try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + )) + let states = try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + )) + let activity = try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + )) + let viewingKeys = try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + )) + let summary = CoreWalletDiagnosticAnalyzer.summarizeShieldedStore( + notes: notes.map { .init(value: $0.value, isSpent: $0.isSpent) }, + outgoingNoteCount: outgoing.count, + activityStatuses: activity.map(\.status), + viewingKeyCount: viewingKeys.count, + syncWatermarks: states.map(\.lastSyncedIndex) + ) + SDKLogger.event( + "shielded_store_snapshot", + category: .shielded, + fields: [ + "activity_count": .integer(Int64(summary.activityCount)), + "activity_failed_count": .integer(Int64(summary.activityFailedCount)), + "activity_pending_count": .integer(Int64(summary.activityPendingCount)), + "checkpoint": .publicText(checkpoint.rawValue), + "maximum_sync_watermark": .unsignedInteger(summary.maximumSyncWatermark), + "note_count": .integer(Int64(summary.noteCount)), + "outgoing_note_count": .integer(Int64(summary.outgoingNoteCount)), + "query_available": .boolean(true), + "spent_note_count": .integer(Int64(summary.spentNoteCount)), + "spent_value_credits": .unsignedInteger(summary.spentValueCredits), + "subwallet_sync_state_count": .integer(Int64(summary.subwalletSyncStateCount)), + "unspent_note_count": .integer(Int64(summary.unspentNoteCount)), + "unspent_value_credits": .unsignedInteger(summary.unspentValueCredits), + "viewing_key_count": .integer(Int64(summary.viewingKeyCount)), + "wallet_reference": .reference(walletId), + ] + ) + } +} + +// MARK: - Rust memory comparison + +@MainActor +extension PlatformWalletManager { + /// Emit a best-effort, read-only snapshot immediately before a diagnostic + /// export. The method intentionally never throws: a failed sub-query is a + /// diagnostic fact and is logged as `unavailable`, not reported as zero. + public func emitCoreWalletDiagnostics(for walletId: Data) async { + await emitCoreWalletDiagnostics(for: walletId, checkpoint: .preExport) + } + + func emitCoreWalletDiagnostics( + for walletId: Data, + checkpoint: CoreWalletDiagnosticCheckpoint + ) async { + guard walletId.count == 32, let handler = persistence else { + SDKLogger.event( + "core_diagnostics_unavailable", + category: .persistence, + severity: .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "reason": .publicText("invalid_wallet_or_persistence_disabled"), + "wallet_reference": .reference(walletId), + ] + ) + return + } + let database = await handler.emitCoreWalletDatabaseDiagnostics( + walletId: walletId, + checkpoint: checkpoint + ) + guard let database else { return } + // The DB await above lets shutdown interleave. Admission is atomic on + // MainActor and keeps the copied handle alive across the off-main FFI + // work; shutdown drains this operation before consuming the handle. + guard isConfigured, handle != NULL_HANDLE else { + SDKLogger.event( + "core_memory_snapshot_unavailable", + category: .persistence, + severity: .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "reason": .publicText("manager_not_configured_after_database_snapshot"), + "wallet_reference": .reference(walletId), + ] + ) + return + } + do { + try admitCoreDiagnosticsNativeOp() + } catch { + SDKLogger.event( + "core_memory_snapshot_unavailable", + category: .persistence, + severity: .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "reason": .publicText("manager_shutdown_in_progress"), + "wallet_reference": .reference(walletId), + ] + ) + return + } + defer { finishCoreDiagnosticsNativeOp() } + + let managerHandle = handle + let managedWallet = wallets[walletId] + await withCheckedContinuation { continuation in + Self.destroyQueue.async { + Self.emitCoreMemoryDiagnostics( + managerHandle: managerHandle, + managedWallet: managedWallet, + database: database, + checkpoint: checkpoint + ) + continuation.resume() + } + } + } + + /// Blocking variant used only by the already-blocking synchronous restore + /// API. New application code should use the async public entry point. + func emitCoreWalletDiagnosticsSynchronously( + for walletId: Data, + checkpoint: CoreWalletDiagnosticCheckpoint + ) { + guard walletId.count == 32, + let handler = persistence, + let database = handler.emitCoreWalletDatabaseDiagnostics( + walletId: walletId, + checkpoint: checkpoint + ), + isConfigured, + handle != NULL_HANDLE + else { return } + Self.emitCoreMemoryDiagnostics( + managerHandle: handle, + managedWallet: wallets[walletId], + database: database, + checkpoint: checkpoint + ) + } + + private nonisolated static func emitCoreMemoryDiagnostics( + managerHandle: Handle, + managedWallet: ManagedPlatformWallet?, + database: CoreWalletDatabaseDiagnosticSnapshot, + checkpoint: CoreWalletDiagnosticCheckpoint + ) { + // Keep the two Rust-memory sources independent: corrupt account state + // must not suppress the AssetLock evidence that can explain a missing + // balance (and vice versa). + compareAssetLocks( + database, + managedWallet: managedWallet, + checkpoint: checkpoint + ) + let balanceQuery = diagnosticAccountBalances( + managerHandle: managerHandle, + walletId: database.walletId + ) + guard case .success(let balances) = balanceQuery else { + SDKLogger.event( + "core_memory_snapshot_unavailable", + category: .persistence, + severity: .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "reason": .publicText("account_balance_query_failed"), + "wallet_reference": .reference(database.walletId), + ] + ) + return + } + + var memoryTxos: [CoreWalletDatabaseDiagnosticSnapshot.Txo] = [] + var unavailableAccounts: Set = [] + let sortedBalances = balances.sorted { + Self.diagnosticAccountKey($0).referenceMaterial.lexicographicallyPrecedes( + Self.diagnosticAccountKey($1).referenceMaterial + ) + } + for balance in sortedBalances { + let key = Self.diagnosticAccountKey(balance) + let query = diagnosticAccountUtxos( + managerHandle: managerHandle, + walletId: database.walletId, + balance: balance + ) + guard case .success(let utxos) = query else { + unavailableAccounts.insert(key) + SDKLogger.event( + "core_memory_account_snapshot", + category: .persistence, + severity: .warning, + fields: [ + "account_reference": .reference(key.referenceMaterial), + "account_type": .unsignedInteger(UInt64(key.typeTag)), + "checkpoint": .publicText(checkpoint.rawValue), + "query_available": .boolean(false), + "wallet_reference": .reference(database.walletId), + ] + ) + continue + } + let materials = utxos.map { + diagnosticTxoFingerprint( + outpoint: $0.outpoint, + amount: $0.amount, + height: $0.height, + scriptPubKey: $0.scriptPubKey, + isLocked: $0.isLocked, + account: key + ) + } + SDKLogger.event( + "core_memory_account_snapshot", + category: .persistence, + fields: [ + "account_index": .unsignedInteger(UInt64(balance.index)), + "account_reference": .reference(key.referenceMaterial), + "account_type": .unsignedInteger(UInt64(balance.typeTag)), + "checkpoint": .publicText(checkpoint.rawValue), + "confirmed_duffs": .unsignedInteger(balance.confirmed), + "immature_duffs": .unsignedInteger(balance.immature), + "locked_duffs": .unsignedInteger(balance.locked), + "query_available": .boolean(true), + "standard_tag": .unsignedInteger(UInt64(balance.standardTag)), + "unconfirmed_duffs": .unsignedInteger(balance.unconfirmed), + "utxo_count": .integer(Int64(utxos.count)), + "utxo_fingerprint": .reference(diagnosticFingerprint(materials)), + "utxo_value_duffs": .unsignedInteger( + diagnosticSaturatingSum(utxos.map(\.amount)) + ), + "wallet_reference": .reference(database.walletId), + ] + ) + memoryTxos.append(contentsOf: utxos) + } + compareDatabase( + database, + memoryTxos: memoryTxos, + memoryAccounts: Set(balances.map(Self.diagnosticAccountKey)), + unavailableAccounts: unavailableAccounts, + checkpoint: checkpoint + ) + } + + private nonisolated static func compareDatabase( + _ database: CoreWalletDatabaseDiagnosticSnapshot, + memoryTxos: [CoreWalletDatabaseDiagnosticSnapshot.Txo], + memoryAccounts: Set, + unavailableAccounts: Set, + checkpoint: CoreWalletDiagnosticCheckpoint + ) { + let excludedDatabaseTxos = database.unspentTxos.filter { row in + row.account.map(unavailableAccounts.contains) ?? false + } + let comparableDatabaseTxos = database.unspentTxos.filter { row in + !(row.account.map(unavailableAccounts.contains) ?? false) + } + let result = CoreWalletDiagnosticAnalyzer.compareTxos( + database: comparableDatabaseTxos, + memory: memoryTxos, + databaseAccounts: Set(database.accounts), + memoryAccounts: memoryAccounts + ) + SDKLogger.event( + "core_db_memory_diff_summary", + category: .persistence, + severity: result.details.isEmpty + && result.databaseAccountOnlyCount == 0 + && result.memoryAccountOnlyCount == 0 + ? .info : .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "common_count": .integer(Int64(result.commonCount)), + "database_account_only_count": .integer( + Int64(result.databaseAccountOnlyCount) + ), + "database_only_count": .integer(Int64(result.databaseOnlyCount)), + "diff_incomplete": .boolean(!unavailableAccounts.isEmpty), + "excluded_database_txo_count": .integer(Int64(excludedDatabaseTxos.count)), + "field_mismatch_count": .integer(Int64(result.fieldMismatchCount)), + "memory_only_count": .integer(Int64(result.memoryOnlyCount)), + "memory_account_only_count": .integer(Int64(result.memoryAccountOnlyCount)), + "truncated_count": .integer(Int64(result.truncatedCount)), + "unavailable_account_count": .integer(Int64(unavailableAccounts.count)), + "wallet_reference": .reference(database.walletId), + ] + ) + for detail in result.emittedDetails { + logDiffItem( + database.walletId, + checkpoint, + detail.row, + detail.outpoint, + detail.reason + ) + } + } + + private nonisolated static func logDiffItem( + _ walletId: Data, + _ checkpoint: CoreWalletDiagnosticCheckpoint, + _ row: CoreWalletDatabaseDiagnosticSnapshot.Txo, + _ outpoint: Data, + _ reason: String + ) { + SDKLogger.event( + "core_db_memory_diff_item", + category: .persistence, + severity: .warning, + fields: [ + "amount_duffs": .unsignedInteger(row.amount), + "checkpoint": .publicText(checkpoint.rawValue), + "height": .unsignedInteger(UInt64(row.height)), + "outpoint_reference": .reference(outpoint), + "reason": .publicText(reason), + "wallet_reference": .reference(walletId), + ] + ) + } + + private nonisolated static func compareAssetLocks( + _ database: CoreWalletDatabaseDiagnosticSnapshot, + managedWallet: ManagedPlatformWallet?, + checkpoint: CoreWalletDiagnosticCheckpoint + ) { + let memory: [ManagedAssetLockManager.TrackedAssetLock] + do { + guard let managedWallet else { + throw PlatformWalletError.notFound("diagnostic wallet is not loaded") + } + memory = try managedWallet.assetLockManager().listTrackedLocks() + } catch { + SDKLogger.event( + "asset_lock_memory_snapshot", + category: .persistence, + severity: .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "query_available": .boolean(false), + "wallet_reference": .reference(database.walletId), + ] + ) + return + } + SDKLogger.event( + "asset_lock_memory_snapshot", + category: .persistence, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "lock_count": .integer(Int64(memory.count)), + "locked_value_duffs": .unsignedInteger( + diagnosticSaturatingSum(memory.map(\.amount)) + ), + "proof_present_count": .integer(Int64(memory.filter(\.hasProof).count)), + "query_available": .boolean(true), + "shielded_funding_count": .integer(Int64(memory.filter { + $0.fundingType == .assetLockShieldedAddressTopUp + }.count)), + "wallet_reference": .reference(database.walletId), + ] + ) + + let groups = Dictionary(grouping: memory) { + "\($0.fundingType.rawValue):\($0.status.rawValue)" + } + for key in groups.keys.sorted() { + guard let group = groups[key], let first = group.first else { continue } + SDKLogger.event( + "asset_lock_memory_group", + category: .persistence, + fields: [ + "amount_duffs": .unsignedInteger( + diagnosticSaturatingSum(group.map(\.amount)) + ), + "checkpoint": .publicText(checkpoint.rawValue), + "count": .integer(Int64(group.count)), + "funding_type": .unsignedInteger(UInt64(first.fundingType.rawValue)), + "proof_present_count": .integer(Int64(group.filter(\.hasProof).count)), + "status": .unsignedInteger(UInt64(first.status.rawValue)), + "wallet_reference": .reference(database.walletId), + ] + ) + } + + let normalizedMemory = memory.map { row in + CoreWalletDatabaseDiagnosticSnapshot.AssetLock( + outpointDisplay: Self.assetLockOutpointDisplay(txid: row.txid, vout: row.vout), + fundingType: Int(row.fundingType.rawValue), + status: Int(row.status.rawValue), + accountIndex: row.accountIndex, + registrationIndex: row.identityIndex, + amountDuffs: row.amount, + hasProof: row.hasProof + ) + } + guard database.assetLocksAvailable else { + SDKLogger.event( + "asset_lock_db_memory_diff_summary", + category: .persistence, + severity: .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "database_query_available": .boolean(false), + "diff_incomplete": .boolean(true), + "mismatch_count": .integer(0), + "truncated_count": .integer(0), + "wallet_reference": .reference(database.walletId), + ] + ) + return + } + let result = CoreWalletDiagnosticAnalyzer.compareAssetLocks( + database: database.assetLocks, + memory: normalizedMemory + ) + SDKLogger.event( + "asset_lock_db_memory_diff_summary", + category: .persistence, + severity: result.details.isEmpty ? .info : .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "database_query_available": .boolean(true), + "diff_incomplete": .boolean(false), + "mismatch_count": .integer(Int64(result.details.count)), + "truncated_count": .integer(Int64(result.truncatedCount)), + "wallet_reference": .reference(database.walletId), + ] + ) + for detail in result.emittedDetails { + SDKLogger.event( + "asset_lock_db_memory_diff_item", + category: .persistence, + severity: .warning, + fields: [ + "checkpoint": .publicText(checkpoint.rawValue), + "outpoint_reference": .referenceString(detail.outpointDisplay), + "reason": .publicText(detail.reason), + "wallet_reference": .reference(database.walletId), + ] + ) + } + } + + private nonisolated static func diagnosticAccountBalances( + managerHandle: Handle, + walletId: Data + ) -> Result<[AccountBalance], PlatformWalletError> { + var outEntries: UnsafePointer? + var outCount: UInt = 0 + let ffi = walletId.withUnsafeBytes { raw in + platform_wallet_manager_get_account_balances( + managerHandle, + raw.baseAddress?.assumingMemoryBound(to: UInt8.self), + &outEntries, + &outCount + ) + } + let result = PlatformWalletResult(ffi) + guard result.isSuccess else { return .failure(PlatformWalletError(result: result)) } + guard let entries = outEntries, outCount > 0 else { return .success([]) } + defer { + platform_wallet_manager_free_account_balances( + UnsafeMutablePointer(mutating: entries), outCount + ) + } + return .success((0.. Result<[CoreWalletDatabaseDiagnosticSnapshot.Txo], PlatformWalletError> { + var spec = AccountSpecFFI() + spec.type_tag = balance.typeTag + spec.standard_tag = balance.standardTag + spec.index = balance.index + spec.registration_index = balance.registrationIndex + spec.key_class = balance.keyClass + _ = Swift.withUnsafeMutableBytes(of: &spec.user_identity_id) { raw in + balance.userIdentityId.copyBytes( + to: raw.bindMemory(to: UInt8.self), + count: min(32, balance.userIdentityId.count) + ) + } + _ = Swift.withUnsafeMutableBytes(of: &spec.friend_identity_id) { raw in + balance.friendIdentityId.copyBytes( + to: raw.bindMemory(to: UInt8.self), + count: min(32, balance.friendIdentityId.count) + ) + } + var outEntries: UnsafePointer? + var outCount: UInt = 0 + let ffi = walletId.withUnsafeBytes { raw in + platform_wallet_account_utxos( + managerHandle, + raw.baseAddress?.assumingMemoryBound(to: UInt8.self), + &spec, + &outEntries, + &outCount + ) + } + let result = PlatformWalletResult(ffi) + guard result.isSuccess else { return .failure(PlatformWalletError(result: result)) } + guard let entries = outEntries, outCount > 0 else { return .success([]) } + defer { + platform_wallet_account_utxos_free( + UnsafeMutablePointer(mutating: entries), outCount + ) + } + let key = Self.diagnosticAccountKey(balance) + return .success((0.. CoreWalletDatabaseDiagnosticSnapshot.AccountKey { + CoreWalletDatabaseDiagnosticSnapshot.AccountKey( + typeTag: UInt32(balance.typeTag), + standardTag: balance.standardTag, + index: balance.index, + registrationIndex: balance.registrationIndex, + keyClass: balance.keyClass, + userIdentityId: balance.userIdentityId, + friendIdentityId: balance.friendIdentityId + ) + } + + private nonisolated static func assetLockOutpointDisplay( + txid: Data, + vout: UInt32 + ) -> String { + let display = txid.reversed().map { String(format: "%02x", $0) }.joined() + return "\(display):\(vout)" + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swift index 88670cb1f2c..b38f4a0a37b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerSPV.swift @@ -91,6 +91,24 @@ public struct PlatformSpvSyncProgress: Sendable, Equatable { } } +enum CoreRescanDiagnosticResult: String, Sendable, Equatable { + case armed + case acceptedNoRewind = "accepted_no_rewind" + case noOp = "no_op" +} + +/// Classifies only what can be proven from the checkpoint visible before the +/// accepted FFI call. A missing checkpoint is not evidence of a rewind. +func coreRescanDiagnosticResult( + previousSyncedHeight: UInt32?, + requestedStartHeight: UInt32 +) -> CoreRescanDiagnosticResult { + guard let previousSyncedHeight else { return .acceptedNoRewind } + if requestedStartHeight < previousSyncedHeight { return .armed } + if requestedStartHeight == previousSyncedHeight { return .noOp } + return .acceptedNoRewind +} + /// Node type of a connected SPV peer, classified against the masternode /// list. Mirrors Rust's `SpvPeerNodeType` / the `SPV_PEER_NODE_TYPE_*` /// FFI constants. @@ -316,12 +334,48 @@ extension PlatformWalletManager { "walletId must be exactly 32 bytes" ) } - try walletId.withUnsafeBytes { widRaw in - guard let widPtr = widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self) - else { - throw PlatformWalletError.invalidParameter("walletId baseAddress is nil") + let previousHeight = coreWalletState(for: walletId)?.syncedHeight + do { + try walletId.withUnsafeBytes { widRaw in + guard let widPtr = widRaw.baseAddress?.assumingMemoryBound(to: UInt8.self) + else { + throw PlatformWalletError.invalidParameter("walletId baseAddress is nil") + } + try platform_wallet_manager_spv_rescan_filters(handle, widPtr, fromHeight).check() } - try platform_wallet_manager_spv_rescan_filters(handle, widPtr, fromHeight).check() + let diagnosticResult = coreRescanDiagnosticResult( + previousSyncedHeight: previousHeight, + requestedStartHeight: fromHeight + ) + var fields: [String: SDKLogValue] = [ + "from_height": .unsignedInteger(UInt64(fromHeight)), + "result": .publicText(diagnosticResult.rawValue), + "wallet_reference": .reference(walletId), + ] + if let previousHeight { + fields["previous_synced_height"] = .unsignedInteger(UInt64(previousHeight)) + } + SDKLogger.event( + "core_rescan_armed", + category: .persistence, + fields: fields + ) + } catch { + var fields: [String: SDKLogValue] = [ + "from_height": .unsignedInteger(UInt64(fromHeight)), + "result": .publicText("failed"), + "wallet_reference": .reference(walletId), + ] + if let previousHeight { + fields["previous_synced_height"] = .unsignedInteger(UInt64(previousHeight)) + } + SDKLogger.event( + "core_rescan_armed", + category: .persistence, + severity: .error, + fields: fields + ) + throw error } } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 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 0000000000000000000000000000000000000000..836d5beeb5a42eaf7579d4bf41067167b1dd018f GIT binary patch literal 62566 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

A%CY_{uSmE?~-EhAQ_;a=!_@ub_7qYnMrD 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