From f31d0cec147652ccd09c86bfde3d7cfc8364e38d Mon Sep 17 00:00:00 2001 From: CypherPoet Date: Mon, 8 Jun 2026 11:46:38 -0500 Subject: [PATCH 1/4] fix: notify on-chain receives that skip mempool When a transaction is confirmed before the wallet sees it unconfirmed, only onchainTransactionConfirmed fires. The received sheet was wired solely to onchainTransactionReceived, so straight-to-confirmed receives showed no notification. Both handlers now share one check-and-show helper. Fixes #455. --- Bitkit/ViewModels/AppViewModel.swift | 54 ++++++++++++++++------------ changelog.d/next/455.fixed.md | 1 + 2 files changed, 32 insertions(+), 23 deletions(-) create mode 100644 changelog.d/next/455.fixed.md diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 5c471c13..1d0d8c99 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -812,6 +812,32 @@ 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 } + 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 +948,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 { diff --git a/changelog.d/next/455.fixed.md b/changelog.d/next/455.fixed.md new file mode 100644 index 00000000..c75daf73 --- /dev/null +++ b/changelog.d/next/455.fixed.md @@ -0,0 +1 @@ +Incoming on-chain transactions that confirm before being seen in the mempool now show the received notification. From 3df9c525b938d79e41883f0552303eec0cdf78d7 Mon Sep 17 00:00:00 2001 From: CypherPoet Date: Mon, 8 Jun 2026 11:47:27 -0500 Subject: [PATCH 2/4] chore: rename changelog fragment --- changelog.d/next/{455.fixed.md => 588.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{455.fixed.md => 588.fixed.md} (100%) diff --git a/changelog.d/next/455.fixed.md b/changelog.d/next/588.fixed.md similarity index 100% rename from changelog.d/next/455.fixed.md rename to changelog.d/next/588.fixed.md From bd077766751f47da7cfe98944a8a1eaae4cd809a Mon Sep 17 00:00:00 2001 From: CypherPoet Date: Mon, 8 Jun 2026 13:44:51 -0500 Subject: [PATCH 3/4] fix: guard received sheet against duplicate presentation Routing both onchainTransactionReceived and onchainTransactionConfirmed through the shared presenter means two tasks can run for one txid. The seen-check and mark were not atomic across awaits, so both events could present the sheet. Reserve the txid synchronously on the MainActor before any await so only the first event presents it. Addresses the PR #588 review. --- Bitkit/ViewModels/AppViewModel.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 1d0d8c99..926f83a9 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? @@ -817,6 +823,13 @@ extension AppViewModel { /// tx that skips the mempool still notifies the user. See issue #455. private func presentReceivedSheetForOnchainTransaction(txid: String, amountSats: Int64) { guard amountSats > 0 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 { From 479aac117cc62b0911d8c373d15f893dd133b7df Mon Sep 17 00:00:00 2001 From: CypherPoet Date: Thu, 11 Jun 2026 18:50:01 -0500 Subject: [PATCH 4/4] fix: suppress received sheet for restored historical receives On a wallet restore the activity store is rebuilt without seenAt, so the initial on-chain sync replays onchainTransactionConfirmed for historical receives. The shared presenter then showed the "Received" sheet for an old transaction, which covered the activity list and timed out the CPFP restore e2e. Set a pendingRestoreActivitySeen flag on the restore success screen, have the presenter bail while it is set, and on the first post-restore on-chain syncCompleted mark all unseen activities as seen and clear the flag. New straight-to-confirmed receives still notify once the flag clears, so #455 stays fixed. Addresses the PR #588 review. --- Bitkit/ViewModels/AppViewModel.swift | 14 ++++++++++++++ Bitkit/ViewModels/SettingsViewModel.swift | 10 ++++++++++ Bitkit/Views/Onboarding/WalletRestoreSuccess.swift | 7 ++++++- 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 926f83a9..31b6752e 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -824,6 +824,11 @@ extension AppViewModel { 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 @@ -1039,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 98ec1d81..2b8f070e 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 9d852fa6..3573914b 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 }