diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 5c471c135..31b6752e6 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -71,6 +71,12 @@ class AppViewModel: ObservableObject { private var pendingPaymentHashes: Set = [] private var pendingContactPaymentContexts: [String: ContactPaymentContext] = [:] + /// Txids for which a received-sheet presentation has already been started this session. + /// The received and confirmed LDK events for the same tx each call the presenter, so this + /// reserves the txid synchronously on the MainActor (before any await) to guarantee the sheet + /// is presented at most once and avoid a double-notification race. See issue #455. + private var receivedSheetInFlightTxids: Set = [] + /// When a payment that was shown on the pending screen succeeds or fails, this is set so SendPendingScreen can navigate. /// Consumed by SendPendingScreen via consumeSendSheetPendingResolution. @Published var sendSheetPendingResolution: SendSheetPendingResolution? @@ -812,6 +818,44 @@ extension AppViewModel { // MARK: LDK Node Events extension AppViewModel { + /// Shows the "received" sheet for an incoming on-chain tx, unless it was already shown. + /// Used by both the received (mempool) and confirmed (straight-to-confirmed) LDK events so a + /// tx that skips the mempool still notifies the user. See issue #455. + private func presentReceivedSheetForOnchainTransaction(txid: String, amountSats: Int64) { + guard amountSats > 0 else { return } + + // During a restore replay, LDK re-fires confirmed events for historical (already-received) txs. + // Suppress the sheet for the whole restore window; the first post-restore on-chain sync marks + // those activities seen and clears this flag, after which genuinely-new receives notify again. #588 + guard !SettingsViewModel.shared.pendingRestoreActivitySeen else { return } + + // Reserve the txid synchronously on the MainActor (no await between check and insert) so the + // received and confirmed events for the same tx can't both pass the seen-check and present the + // sheet twice. The persisted seenAt still handles cross-launch dedup; this closes the in-session + // concurrency race. + guard receivedSheetInFlightTxids.insert(txid).inserted else { return } + + let sats = UInt64(amountSats) + + Task { + // 500ms delay so the activity is written to the DB before the dedup/filter checks read it. + try? await Task.sleep(nanoseconds: 500_000_000) + + if await CoreService.shared.activity.isOnchainActivitySeen(txid: txid) { + return + } + + let shouldShow = await CoreService.shared.activity.shouldShowReceivedSheet(txid: txid, value: sats) + guard shouldShow else { return } + + await CoreService.shared.activity.markOnchainActivityAsSeen(txid: txid) + + await MainActor.run { + sheetViewModel.showSheet(.receivedTx, data: ReceivedTxSheetDetails(type: .onchain, sats: sats)) + } + } + } + func handleLdkNodeEvent(_ event: Event) { switch event { case let .paymentReceived(paymentId, _, amountMsat, _): @@ -922,30 +966,12 @@ extension AppViewModel { // MARK: New Onchain Transaction Events case let .onchainTransactionReceived(txid, details): - // Show notification for incoming transactions - if details.amountSats > 0 { - let sats = UInt64(abs(Int64(details.amountSats))) - - Task { - // Show sheet for new transactions or replacements with value changes - try? await Task.sleep(nanoseconds: 500_000_000) // 500ms delay - - if await CoreService.shared.activity.isOnchainActivitySeen(txid: txid) { - return - } - - let shouldShow = await CoreService.shared.activity.shouldShowReceivedSheet(txid: txid, value: sats) - guard shouldShow else { return } - - await CoreService.shared.activity.markOnchainActivityAsSeen(txid: txid) - - await MainActor.run { - sheetViewModel.showSheet(.receivedTx, data: ReceivedTxSheetDetails(type: .onchain, sats: sats)) - } - } - } - case let .onchainTransactionConfirmed(txid, _, blockHeight, _, _): + // Show notification for incoming transactions seen in the mempool + presentReceivedSheetForOnchainTransaction(txid: txid, amountSats: details.amountSats) + case let .onchainTransactionConfirmed(txid, _, blockHeight, _, details): Logger.info("Transaction confirmed: \(txid) at block \(blockHeight)") + // Also notify when a tx goes straight to confirmed without a prior received event + presentReceivedSheetForOnchainTransaction(txid: txid, amountSats: details.amountSats) case let .onchainTransactionReplaced(txid, conflicts): Logger.info("Transaction replaced: \(txid) by \(conflicts.count) conflict(s)") Task { @@ -1018,6 +1044,15 @@ extension AppViewModel { } } + // After a seed restore, the first on-chain sync has now discovered the historical txs. + // Mark them seen so they don't pop a "Received" sheet, and lift the restore suppression. #588 + if SettingsViewModel.shared.pendingRestoreActivitySeen, syncType == .onchainWallet { + SettingsViewModel.shared.pendingRestoreActivitySeen = false + Task { @MainActor in + await CoreService.shared.activity.markAllUnseenActivitiesAsSeen() + } + } + if MigrationsService.shared.needsPostMigrationSync { Task { @MainActor in try? await CoreService.shared.activity.syncLdkNodePayments(LightningService.shared.listPayments() ?? []) diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index 98ec1d817..2b8f070ee 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -404,6 +404,16 @@ class SettingsViewModel: NSObject, ObservableObject { set { UserDefaults.standard.set(newValue, forKey: Self.pendingRestoreAddressTypePruneKey) } } + private static let pendingRestoreActivitySeenKey = "pendingRestoreActivitySeen" + + /// After a seed restore, suppress on-chain "Received" sheets for replayed historical txs until the + /// first post-restore on-chain sync completes, then mark them seen. Set when user taps Get Started; + /// cleared in AppViewModel's syncCompleted(.onchainWallet) handler. + var pendingRestoreActivitySeen: Bool { + get { UserDefaults.standard.bool(forKey: Self.pendingRestoreActivitySeenKey) } + set { UserDefaults.standard.set(newValue, forKey: Self.pendingRestoreActivitySeenKey) } + } + /// After restore, disables monitoring for address types with zero balance. /// Keeps nativeSegwit as primary and monitored; only types with funds stay monitored. func pruneEmptyAddressTypesAfterRestore() async { diff --git a/Bitkit/Views/Onboarding/WalletRestoreSuccess.swift b/Bitkit/Views/Onboarding/WalletRestoreSuccess.swift index 9d852fa69..3573914bd 100644 --- a/Bitkit/Views/Onboarding/WalletRestoreSuccess.swift +++ b/Bitkit/Views/Onboarding/WalletRestoreSuccess.swift @@ -36,8 +36,13 @@ struct WalletRestoreSuccess: View { app.backupVerified = true wallet.isRestoringWallet = false - // Skip pruning if backup had explicit monitored address types let settings = SettingsViewModel.shared + + // Suppress "Received" sheets for historical txs replayed during the post-restore sync. + // Cleared on the first post-restore on-chain syncCompleted, which marks them seen. #588 + settings.pendingRestoreActivitySeen = true + + // Skip pruning if backup had explicit monitored address types if !settings.restoredMonitoredTypesFromBackup { settings.pendingRestoreAddressTypePrune = true } diff --git a/changelog.d/next/588.fixed.md b/changelog.d/next/588.fixed.md new file mode 100644 index 000000000..c75daf731 --- /dev/null +++ b/changelog.d/next/588.fixed.md @@ -0,0 +1 @@ +Incoming on-chain transactions that confirm before being seen in the mempool now show the received notification.