Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 58 additions & 23 deletions Bitkit/ViewModels/AppViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ class AppViewModel: ObservableObject {
private var pendingPaymentHashes: Set<String> = []
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<String> = []

/// 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?
Expand Down Expand Up @@ -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, _):
Expand Down Expand Up @@ -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)
Comment thread
CypherPoet marked this conversation as resolved.
case let .onchainTransactionReplaced(txid, conflicts):
Logger.info("Transaction replaced: \(txid) by \(conflicts.count) conflict(s)")
Task {
Expand Down Expand Up @@ -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() ?? [])
Expand Down
10 changes: 10 additions & 0 deletions Bitkit/ViewModels/SettingsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 6 additions & 1 deletion Bitkit/Views/Onboarding/WalletRestoreSuccess.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/588.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Incoming on-chain transactions that confirm before being seen in the mempool now show the received notification.
Loading