Skip to content
Merged
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
1 change: 1 addition & 0 deletions Bitkit/Components/CopyAddressCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ struct CopyAddressCard: View {
.lineLimit(2)
.truncationMode(.middle)
.padding(.bottom, 12)
.accessibilityIdentifier(pair.type == .onchain ? "ReceiveOnchainAddress" : "ReceiveLightningAddress")

HStack(spacing: 8) {
if let editRoute {
Expand Down
19 changes: 18 additions & 1 deletion Bitkit/Services/CoreService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,14 @@ class ActivityService {
}

/// Marks every unseen activity across all wallets as seen, each under its own wallet id.
func markAllUnseenActivitiesAsSeen() async {
///
/// `startedBefore` limits the pass to activity that already existed at that time. The restore
/// sweep passes the moment the restore began, so a payment that genuinely arrives while the
/// restore is still running keeps its unseen state and still notifies the user. #588
///
/// Returns `false` when the pass did not complete, so callers can keep any suppression they hold.
@discardableResult
func markAllUnseenActivitiesAsSeen(startedBefore cutoff: UInt64? = nil) async -> Bool {
let timestamp = UInt64(Date().timeIntervalSince1970)

do {
Expand All @@ -221,16 +228,23 @@ class ActivityService {
let id: String
let walletId: String
let isSeen: Bool
let createdAt: UInt64

switch activity {
case let .onchain(onchain):
id = onchain.id
walletId = onchain.walletId
isSeen = onchain.seenAt != nil
createdAt = onchain.timestamp
case let .lightning(lightning):
id = lightning.id
walletId = lightning.walletId
isSeen = lightning.seenAt != nil
createdAt = lightning.timestamp
}

if let cutoff, createdAt > cutoff {
continue
}

if !isSeen {
Expand All @@ -244,8 +258,11 @@ class ActivityService {
if didMarkAny {
activitiesChangedSubject.send()
}

return true
} catch {
Logger.error("Failed to mark all activities as seen: \(error)", context: "ActivityService")
return false
}
}

Expand Down
199 changes: 177 additions & 22 deletions Bitkit/ViewModels/AppViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,20 @@ class AppViewModel: ObservableObject {
private(set) var isQuickPayActive = false
private var quickPayPaymentHash: String?

/// 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> = []

/// On-chain receives that arrived while the restore sweep ran, in arrival order, to present once
/// the hold lifts. Whatever the first restore sync emitted is dropped instead: it cannot tell a
/// payment arriving mid-scan from an unconfirmed one the scan replays. #588
private(set) var restoreHeldReceives: [RestoreHeldReceive] = []

/// Whether the post-restore sweep is running, so a later sync cannot start a second one.
private var isCompletingRestoreHold = false

/// 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 @@ -1202,7 +1216,144 @@ extension AppViewModel {

// MARK: LDK Node Events

/// An on-chain receive held by the restore hold. `blockHeight` and `confirmationTime` are nil for a
/// mempool receive.
struct RestoreHeldReceive: Equatable {
let txid: String
let amountSats: Int64
let blockHeight: UInt32?
let confirmationTime: UInt64?
}

extension AppViewModel {
/// Lifts the post-restore received-sheet suppression, once the activities the first post-restore
/// on-chain sync replayed have actually been marked seen.
///
/// The flag must outlive the marking pass: clearing it up front reopens
/// `presentReceivedSheetForOnchainTransaction` while the pass is still running, and leaves it open
/// for good if the pass fails, so a historical tx can pop a "Received" sheet. #588
///
/// Records `syncedBlockHeight` as the restore's chain tip, so confirmations the restore already
/// scanned stay silent after the hold too, then presents the receives held during the sweep.
/// Call `beginCompletingPendingRestoreActivitySeen` first, synchronously in the event handler.
@MainActor
func completePendingRestoreActivitySeen(
syncedBlockHeight: UInt32,
markAllSeen: (UInt64) async -> Bool = { cutoff in
await CoreService.shared.activity.markAllUnseenActivitiesAsSeen(startedBefore: cutoff)
},
presentReceive: ((String, Int64) -> Void)? = nil
) async {
defer { isCompletingRestoreHold = false }
let restoreStartedAt = SettingsViewModel.shared.pendingRestoreActivitySeenSince
guard restoreStartedAt > 0 else { return }
guard await markAllSeen(restoreStartedAt) else { return }
SettingsViewModel.shared.restoreSyncedBlockHeight = syncedBlockHeight
SettingsViewModel.shared.pendingRestoreActivitySeenSince = 0

let held = restoreHeldReceives
restoreHeldReceives.removeAll()
let present = presentReceive ?? { [weak self] txid, amountSats in
self?.presentReceivedSheetForOnchainTransaction(txid: txid, amountSats: amountSats)
}
for receive in held {
if let blockHeight = receive.blockHeight, let confirmationTime = receive.confirmationTime {
guard Self.shouldPresentConfirmedOnlyReceive(
confirmationTime: confirmationTime,
blockHeight: blockHeight,
restoreSyncedBlockHeight: syncedBlockHeight
) else { continue }
}
present(receive.txid, receive.amountSats)
}
}

/// Starts lifting the restore hold on an on-chain `syncCompleted`, returning whether the caller
/// should run `completePendingRestoreActivitySeen`. Runs synchronously in the event handler: the
/// receives held so far came from the restore scan itself and are dropped as history, and only
/// one sweep runs, so a later sync cannot raise the recorded restore tip. #588
func beginCompletingPendingRestoreActivitySeen() -> Bool {
guard SettingsViewModel.shared.pendingRestoreActivitySeen, !isCompletingRestoreHold else { return false }
restoreHeldReceives.removeAll()
isCompletingRestoreHold = true
return true
}

/// Holds an on-chain receive while the restore hold is up, returning whether it was held.
/// `blockHeight` and `confirmationTime` are set for a confirmed event, nil for a mempool one.
@discardableResult
func holdReceiveDuringRestore(
txid: String,
amountSats: Int64,
blockHeight: UInt32? = nil,
confirmationTime: UInt64? = nil
) -> Bool {
guard SettingsViewModel.shared.pendingRestoreActivitySeen else { return false }
guard amountSats > 0 else { return true }
Logger.debug("Skipping received sheet for tx \(txid) until the restore sweep finishes")
if !restoreHeldReceives.contains(where: { $0.txid == txid }) {
restoreHeldReceives.append(
RestoreHeldReceive(txid: txid, amountSats: amountSats, blockHeight: blockHeight, confirmationTime: confirmationTime)
)
}
return true
}

/// 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.
/// Max distance between a confirmed-only tx's block time and the device clock for it to count as a new
/// receive. A full wallet scan, as after a migration or when an address type starts being monitored,
/// replays confirmed events for old txs, and those stay silent. Absolute because block timestamps and
/// device clocks can each run ahead of the other. Matches `MAX_CONFIRMED_ONLY_AGE` on Android.
static let maxConfirmedOnlyReceiveAge: TimeInterval = 60 * 60

/// Whether a confirmed event is a new receive. Also skips any block at or below the tip the latest
/// seed restore scanned, since a later rescan replays those with block times that can still fall
/// inside the window. #588
static func shouldPresentConfirmedOnlyReceive(
confirmationTime: UInt64,
blockHeight: UInt32,
now: Date = Date(),
isMigrating: Bool = MigrationsService.shared.isShowingMigrationLoading || MigrationsService.shared.needsPostMigrationSync,
restoreSyncedBlockHeight: UInt32? = nil
) -> Bool {
guard !isMigrating else { return false }
guard blockHeight > (restoreSyncedBlockHeight ?? SettingsViewModel.shared.restoreSyncedBlockHeight) else { return false }
let age = abs(now.timeIntervalSince1970 - TimeInterval(confirmationTime))
return age <= maxConfirmedOnlyReceiveAge
}

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 {
// 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 @@ -1345,30 +1496,26 @@ 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))
}
}
// Show notification for incoming transactions seen in the mempool, once any restore hold lifts
if !holdReceiveDuringRestore(txid: txid, amountSats: details.amountSats) {
presentReceivedSheetForOnchainTransaction(txid: txid, amountSats: details.amountSats)
}
case let .onchainTransactionConfirmed(txid, _, blockHeight, _, _):
case let .onchainTransactionConfirmed(txid, _, blockHeight, confirmationTime, details):
Logger.info("Transaction confirmed: \(txid) at block \(blockHeight)")
// Also notify when a tx goes straight to confirmed without a prior received event
if holdReceiveDuringRestore(
txid: txid,
amountSats: details.amountSats,
blockHeight: blockHeight,
confirmationTime: confirmationTime
) {
break
}
if Self.shouldPresentConfirmedOnlyReceive(confirmationTime: confirmationTime, blockHeight: blockHeight) {
presentReceivedSheetForOnchainTransaction(txid: txid, amountSats: details.amountSats)
} else {
Logger.debug("Skipping received sheet for confirmed-only tx \(txid) confirmed at \(confirmationTime), height \(blockHeight)")
}
case let .onchainTransactionReplaced(txid, conflicts):
Logger.info("Transaction replaced: \(txid) by \(conflicts.count) conflict(s)")
Task {
Expand Down Expand Up @@ -1441,6 +1588,14 @@ 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 syncType == .onchainWallet, beginCompletingPendingRestoreActivitySeen() {
Task { @MainActor in
await self.completePendingRestoreActivitySeen(syncedBlockHeight: syncedBlockHeight)
}
}

if MigrationsService.shared.needsPostMigrationSync {
Task { @MainActor in
try? await CoreService.shared.activity.syncLdkNodePayments(LightningService.shared.listPayments() ?? [])
Expand Down
30 changes: 30 additions & 0 deletions Bitkit/ViewModels/SettingsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,36 @@ class SettingsViewModel: NSObject, ObservableObject {
set { UserDefaults.standard.set(newValue, forKey: Self.pendingRestoreAddressTypePruneKey) }
}

private static let pendingRestoreActivitySeenSinceKey = "pendingRestoreActivitySeenSince"

/// When a seed restore began, or 0 when no restore is being suppressed.
///
/// Set as the restore starts, before the node is started, so the replayed historical txs cannot
/// slip a "Received" sheet through ahead of the flag. Doubles as the cutoff for the sweep that
/// marks those txs seen, so a payment arriving mid-restore is not swept up with them. Cleared in
/// AppViewModel's syncCompleted(.onchainWallet) handler once that sweep succeeds. #588
var pendingRestoreActivitySeenSince: UInt64 {
get { UInt64(UserDefaults.standard.double(forKey: Self.pendingRestoreActivitySeenSinceKey)) }
set { UserDefaults.standard.set(Double(newValue), forKey: Self.pendingRestoreActivitySeenSinceKey) }
}

/// Whether replayed restore activity is still being suppressed.
var pendingRestoreActivitySeen: Bool {
pendingRestoreActivitySeenSince > 0
}

private static let restoreSyncedBlockHeightKey = "restoreSyncedBlockHeight"

/// Chain tip of the first on-chain sync after the latest seed restore, or 0 when none completed.
///
/// Everything confirmed at or below it was already on chain when the restore scanned the wallet, so
/// it outlives the restore hold: a later rescan replays confirmations for those txs, and they must
/// stay silent however long after the hold their events are handled. #588
var restoreSyncedBlockHeight: UInt32 {
get { UInt32(clamping: UserDefaults.standard.integer(forKey: Self.restoreSyncedBlockHeightKey)) }
set { UserDefaults.standard.set(Int(newValue), forKey: Self.restoreSyncedBlockHeightKey) }
}

/// 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
9 changes: 9 additions & 0 deletions Bitkit/Views/Onboarding/RestoreWalletView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -273,12 +273,21 @@ struct RestoreWalletView: View {
// Prevent settings changes from triggering backups before the actual restore runs
BackupService.shared.setRestoring(true)

// Suppress "Received" sheets for the historical txs the restore replays. Set here, before
// the node is started, because startup sync begins as soon as the wallet exists - setting
// it on the Get Started tap left a window where replayed txs could still pop a sheet. #588
SettingsViewModel.shared.pendingRestoreActivitySeenSince = UInt64(Date().timeIntervalSince1970)
Comment thread
jvsena42 marked this conversation as resolved.
SettingsViewModel.shared.restoreSyncedBlockHeight = 0

// When restoring a wallet, monitor all address types to catch any existing funds
SettingsViewModel.shared.monitorAllAddressTypes()

_ = try StartupHandler.restoreWallet(mnemonic: bip39Mnemonic, bip39Passphrase: bip39Passphrase)
try wallet.setWalletExistsState()
} catch {
// The node never started, so no sync will lift the hold. Left in place it would silence
// every later on-chain receive, across retries and relaunches. #588
SettingsViewModel.shared.pendingRestoreActivitySeenSince = 0
BackupService.shared.setRestoring(false)
app.toast(error)
}
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

// Note: the "Received" sheet suppression for replayed historical txs is armed when the
// restore starts, in RestoreWalletView, not here - by this tap the node has already
// been syncing for a while. #588

// Skip pruning if backup had explicit monitored address types
if !settings.restoredMonitoredTypesFromBackup {
settings.pendingRestoreAddressTypePrune = true
}
Expand Down
Loading
Loading