From f31d0cec147652ccd09c86bfde3d7cfc8364e38d Mon Sep 17 00:00:00 2001 From: CypherPoet Date: Mon, 8 Jun 2026 11:46:38 -0500 Subject: [PATCH 1/8] 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 5c471c135..1d0d8c991 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 000000000..c75daf731 --- /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/8] 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/8] 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 1d0d8c991..926f83a95 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/8] 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 926f83a95..31b6752e6 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 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 } From 6891cea7f689ac2057492b2ab256895936736f52 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 23 Sep 2026 07:14:37 -0300 Subject: [PATCH 5/8] fix: lift restore sheet hold only after activities are seen Co-Authored-By: Claude Opus 5 (1M context) --- Bitkit/Services/CoreService.swift | 7 ++- Bitkit/ViewModels/AppViewModel.swift | 20 ++++++- .../RestoreActivitySeenSuppressionTests.swift | 58 +++++++++++++++++++ 3 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 BitkitTests/RestoreActivitySeenSuppressionTests.swift diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index bafe61e12..3bff51b7e 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -210,7 +210,9 @@ class ActivityService { } /// Marks every unseen activity across all wallets as seen, each under its own wallet id. - func markAllUnseenActivitiesAsSeen() async { + /// Returns `false` when the pass did not complete, so callers can keep any suppression they hold. #588 + @discardableResult + func markAllUnseenActivitiesAsSeen() async -> Bool { let timestamp = UInt64(Date().timeIntervalSince1970) do { @@ -244,8 +246,11 @@ class ActivityService { if didMarkAny { activitiesChangedSubject.send() } + + return true } catch { Logger.error("Failed to mark all activities as seen: \(error)", context: "ActivityService") + return false } } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 80121efa2..11b2b8040 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -1198,6 +1198,21 @@ extension AppViewModel { // MARK: LDK Node Events 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 + @MainActor + func completePendingRestoreActivitySeen( + markAllSeen: () async -> Bool = { await CoreService.shared.activity.markAllUnseenActivitiesAsSeen() } + ) async { + guard SettingsViewModel.shared.pendingRestoreActivitySeen else { return } + guard await markAllSeen() else { return } + SettingsViewModel.shared.pendingRestoreActivitySeen = false + } + /// 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. @@ -1458,10 +1473,9 @@ 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 + if syncType == .onchainWallet { Task { @MainActor in - await CoreService.shared.activity.markAllUnseenActivitiesAsSeen() + await self.completePendingRestoreActivitySeen() } } diff --git a/BitkitTests/RestoreActivitySeenSuppressionTests.swift b/BitkitTests/RestoreActivitySeenSuppressionTests.swift new file mode 100644 index 000000000..898113dbf --- /dev/null +++ b/BitkitTests/RestoreActivitySeenSuppressionTests.swift @@ -0,0 +1,58 @@ +@testable import Bitkit +import XCTest + +/// Regression cover for #588: the post-restore received-sheet suppression must outlive the pass that +/// marks the replayed activities as seen. +/// +/// Clearing `pendingRestoreActivitySeen` up front reopened +/// `presentReceivedSheetForOnchainTransaction` while the marking pass was still running — and kept it +/// open when the pass failed — so a historical tx replayed by LDK could pop a "Received" sheet. +@MainActor +final class RestoreActivitySeenSuppressionTests: XCTestCase { + private let flagKey = "pendingRestoreActivitySeen" + + override func setUp() { + super.setUp() + snapshotAppDefaults(flagKey) + } + + func testSuppressionHoldsUntilTheMarkingPassFinishes() async { + SettingsViewModel.shared.pendingRestoreActivitySeen = true + let app = AppViewModel() + var flagDuringPass: Bool? + + await app.completePendingRestoreActivitySeen { + flagDuringPass = SettingsViewModel.shared.pendingRestoreActivitySeen + return true + } + + XCTAssertEqual(flagDuringPass, true, "suppression was lifted before the activities were marked seen") + XCTAssertFalse(SettingsViewModel.shared.pendingRestoreActivitySeen) + } + + func testSuppressionIsKeptWhenTheMarkingPassFails() async { + SettingsViewModel.shared.pendingRestoreActivitySeen = true + let app = AppViewModel() + + await app.completePendingRestoreActivitySeen { false } + + XCTAssertTrue( + SettingsViewModel.shared.pendingRestoreActivitySeen, + "a failed marking pass must keep the restore suppression, or replayed txs pop a sheet" + ) + } + + func testMarkingPassIsSkippedWhenNoRestoreIsPending() async { + SettingsViewModel.shared.pendingRestoreActivitySeen = false + let app = AppViewModel() + var didRunPass = false + + await app.completePendingRestoreActivitySeen { + didRunPass = true + return true + } + + XCTAssertFalse(didRunPass, "every on-chain sync would re-mark all activities seen") + XCTAssertFalse(SettingsViewModel.shared.pendingRestoreActivitySeen) + } +} From 9a6e98d211857e5114924f8cfca429c6df2873fe Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 23 Sep 2026 13:53:06 -0300 Subject: [PATCH 6/8] fix: arm restore sheet hold before the node starts syncing Co-Authored-By: Claude Opus 5 (1M context) --- Bitkit/Services/CoreService.swift | 16 ++- Bitkit/ViewModels/AppViewModel.swift | 11 +- Bitkit/ViewModels/SettingsViewModel.swift | 20 +++- .../Views/Onboarding/RestoreWalletView.swift | 5 + .../Onboarding/WalletRestoreSuccess.swift | 6 +- .../MarkAllUnseenActivitiesCutoffTests.swift | 103 ++++++++++++++++++ .../RestoreActivitySeenSuppressionTests.swift | 46 ++++++-- 7 files changed, 183 insertions(+), 24 deletions(-) create mode 100644 BitkitTests/MarkAllUnseenActivitiesCutoffTests.swift diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 3bff51b7e..831b7ba9d 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -210,9 +210,14 @@ class ActivityService { } /// Marks every unseen activity across all wallets as seen, each under its own wallet id. - /// Returns `false` when the pass did not complete, so callers can keep any suppression they hold. #588 + /// + /// `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() async -> Bool { + func markAllUnseenActivitiesAsSeen(startedBefore cutoff: UInt64? = nil) async -> Bool { let timestamp = UInt64(Date().timeIntervalSince1970) do { @@ -223,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 { diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 11b2b8040..310d3748d 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -1206,11 +1206,14 @@ extension AppViewModel { /// for good if the pass fails, so a historical tx can pop a "Received" sheet. #588 @MainActor func completePendingRestoreActivitySeen( - markAllSeen: () async -> Bool = { await CoreService.shared.activity.markAllUnseenActivitiesAsSeen() } + markAllSeen: (UInt64) async -> Bool = { cutoff in + await CoreService.shared.activity.markAllUnseenActivitiesAsSeen(startedBefore: cutoff) + } ) async { - guard SettingsViewModel.shared.pendingRestoreActivitySeen else { return } - guard await markAllSeen() else { return } - SettingsViewModel.shared.pendingRestoreActivitySeen = false + let restoreStartedAt = SettingsViewModel.shared.pendingRestoreActivitySeenSince + guard restoreStartedAt > 0 else { return } + guard await markAllSeen(restoreStartedAt) else { return } + SettingsViewModel.shared.pendingRestoreActivitySeenSince = 0 } /// Shows the "received" sheet for an incoming on-chain tx, unless it was already shown. diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index fd096e17d..b1b406b28 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -458,14 +458,22 @@ class SettingsViewModel: NSObject, ObservableObject { set { UserDefaults.standard.set(newValue, forKey: Self.pendingRestoreAddressTypePruneKey) } } - private static let pendingRestoreActivitySeenKey = "pendingRestoreActivitySeen" + private static let pendingRestoreActivitySeenSinceKey = "pendingRestoreActivitySeenSince" - /// 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. + /// 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 { - get { UserDefaults.standard.bool(forKey: Self.pendingRestoreActivitySeenKey) } - set { UserDefaults.standard.set(newValue, forKey: Self.pendingRestoreActivitySeenKey) } + pendingRestoreActivitySeenSince > 0 } /// After restore, disables monitoring for address types with zero balance. diff --git a/Bitkit/Views/Onboarding/RestoreWalletView.swift b/Bitkit/Views/Onboarding/RestoreWalletView.swift index 3d960b6c5..de979d982 100644 --- a/Bitkit/Views/Onboarding/RestoreWalletView.swift +++ b/Bitkit/Views/Onboarding/RestoreWalletView.swift @@ -257,6 +257,11 @@ 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) + // When restoring a wallet, monitor all address types to catch any existing funds SettingsViewModel.shared.monitorAllAddressTypes() diff --git a/Bitkit/Views/Onboarding/WalletRestoreSuccess.swift b/Bitkit/Views/Onboarding/WalletRestoreSuccess.swift index 3573914bd..9e0d90114 100644 --- a/Bitkit/Views/Onboarding/WalletRestoreSuccess.swift +++ b/Bitkit/Views/Onboarding/WalletRestoreSuccess.swift @@ -38,9 +38,9 @@ struct WalletRestoreSuccess: View { 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 + // 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 { diff --git a/BitkitTests/MarkAllUnseenActivitiesCutoffTests.swift b/BitkitTests/MarkAllUnseenActivitiesCutoffTests.swift new file mode 100644 index 000000000..aa5035dcf --- /dev/null +++ b/BitkitTests/MarkAllUnseenActivitiesCutoffTests.swift @@ -0,0 +1,103 @@ +@testable import Bitkit +import BitkitCore +import XCTest + +/// Regression cover for #588: the post-restore sweep marks the replayed historical activity as seen, +/// and must leave alone a payment that genuinely arrives while the restore is still running. +/// +/// Without the cutoff, such a payment was suppressed by `pendingRestoreActivitySeen` on arrival and +/// then marked seen by the sweep, so it never notified the user at all. +final class MarkAllUnseenActivitiesCutoffTests: XCTestCase { + private let testDbPath = NSTemporaryDirectory() + private let service = CoreService.shared.activity + + private let restoreStartedAt: UInt64 = 1_700_000_000 + + override func setUp() async throws { + try await super.setUp() + _ = try initDb(basePath: testDbPath) + try await Task.sleep(nanoseconds: 1_000_000_000) + } + + override func tearDown() async throws { + try await super.tearDown() + + let fileManager = FileManager.default + let dbPath = (testDbPath as NSString).appendingPathComponent("activity.db") + if fileManager.fileExists(atPath: dbPath) { + try fileManager.removeItem(atPath: dbPath) + } + } + + func testSweepMarksReplayedHistoryButSparesNewerActivity() async throws { + let replayed = "restore-replayed-history" + let arrivedDuringRestore = "arrived-mid-restore" + + try await service.insert(onchainActivity(id: replayed, txId: "old", timestamp: restoreStartedAt - 3600)) + try await service.insert( + onchainActivity(id: arrivedDuringRestore, txId: "new", timestamp: restoreStartedAt + 30) + ) + + let completed = await service.markAllUnseenActivitiesAsSeen(startedBefore: restoreStartedAt) + + let replayedSeenAt = try await seenAt(of: replayed) + let newerSeenAt = try await seenAt(of: arrivedDuringRestore) + + XCTAssertTrue(completed) + XCTAssertNotNil(replayedSeenAt, "replayed history should be marked seen by the sweep") + XCTAssertNil( + newerSeenAt, + "a payment that arrived during the restore must stay unseen, or it never notifies" + ) + } + + func testSweepWithoutACutoffStillMarksEverything() async throws { + let id = "no-cutoff" + try await service.insert(onchainActivity(id: id, txId: "any", timestamp: restoreStartedAt + 30)) + + let completed = await service.markAllUnseenActivitiesAsSeen() + + let markedSeenAt = try await seenAt(of: id) + + XCTAssertTrue(completed) + XCTAssertNotNil(markedSeenAt, "the post-migration caller passes no cutoff and expects a full sweep") + } + + // MARK: - Helpers + + private func seenAt(of id: String) async throws -> UInt64? { + guard case let .onchain(activity) = try await service.getActivity(id: id) else { + XCTFail("activity \(id) was not stored as on-chain") + return nil + } + return activity.seenAt + } + + private func onchainActivity(id: String, txId: String, timestamp: UInt64) -> Activity { + .onchain( + OnchainActivity( + walletId: WalletScope.default, + id: id, + txType: .received, + txId: txId, + value: 10000, + fee: 100, + feeRate: 1, + address: "bc1...", + confirmed: true, + timestamp: timestamp, + isBoosted: false, + boostTxIds: [], + isTransfer: false, + doesExist: true, + confirmTimestamp: nil, + channelId: nil, + transferTxId: nil, + contact: nil, + createdAt: nil, + updatedAt: nil, + seenAt: nil + ) + ) + } +} diff --git a/BitkitTests/RestoreActivitySeenSuppressionTests.swift b/BitkitTests/RestoreActivitySeenSuppressionTests.swift index 898113dbf..83f72feb8 100644 --- a/BitkitTests/RestoreActivitySeenSuppressionTests.swift +++ b/BitkitTests/RestoreActivitySeenSuppressionTests.swift @@ -2,14 +2,16 @@ import XCTest /// Regression cover for #588: the post-restore received-sheet suppression must outlive the pass that -/// marks the replayed activities as seen. +/// marks the replayed activities as seen, and that pass must not sweep up payments that arrive while +/// the restore is still running. /// -/// Clearing `pendingRestoreActivitySeen` up front reopened +/// Clearing `pendingRestoreActivitySeenSince` up front reopened /// `presentReceivedSheetForOnchainTransaction` while the marking pass was still running — and kept it /// open when the pass failed — so a historical tx replayed by LDK could pop a "Received" sheet. @MainActor final class RestoreActivitySeenSuppressionTests: XCTestCase { - private let flagKey = "pendingRestoreActivitySeen" + private let flagKey = "pendingRestoreActivitySeenSince" + private let restoreStartedAt: UInt64 = 1_700_000_000 override func setUp() { super.setUp() @@ -17,11 +19,11 @@ final class RestoreActivitySeenSuppressionTests: XCTestCase { } func testSuppressionHoldsUntilTheMarkingPassFinishes() async { - SettingsViewModel.shared.pendingRestoreActivitySeen = true + SettingsViewModel.shared.pendingRestoreActivitySeenSince = restoreStartedAt let app = AppViewModel() var flagDuringPass: Bool? - await app.completePendingRestoreActivitySeen { + await app.completePendingRestoreActivitySeen { _ in flagDuringPass = SettingsViewModel.shared.pendingRestoreActivitySeen return true } @@ -31,10 +33,10 @@ final class RestoreActivitySeenSuppressionTests: XCTestCase { } func testSuppressionIsKeptWhenTheMarkingPassFails() async { - SettingsViewModel.shared.pendingRestoreActivitySeen = true + SettingsViewModel.shared.pendingRestoreActivitySeenSince = restoreStartedAt let app = AppViewModel() - await app.completePendingRestoreActivitySeen { false } + await app.completePendingRestoreActivitySeen { _ in false } XCTAssertTrue( SettingsViewModel.shared.pendingRestoreActivitySeen, @@ -43,11 +45,11 @@ final class RestoreActivitySeenSuppressionTests: XCTestCase { } func testMarkingPassIsSkippedWhenNoRestoreIsPending() async { - SettingsViewModel.shared.pendingRestoreActivitySeen = false + SettingsViewModel.shared.pendingRestoreActivitySeenSince = 0 let app = AppViewModel() var didRunPass = false - await app.completePendingRestoreActivitySeen { + await app.completePendingRestoreActivitySeen { _ in didRunPass = true return true } @@ -55,4 +57,30 @@ final class RestoreActivitySeenSuppressionTests: XCTestCase { XCTAssertFalse(didRunPass, "every on-chain sync would re-mark all activities seen") XCTAssertFalse(SettingsViewModel.shared.pendingRestoreActivitySeen) } + + /// The sweep is bounded by when the restore began, so a payment that genuinely arrives mid-restore + /// keeps its unseen state instead of being marked seen along with the replayed history. + func testMarkingPassIsBoundedByTheRestoreStartTime() async { + SettingsViewModel.shared.pendingRestoreActivitySeenSince = restoreStartedAt + let app = AppViewModel() + var passedCutoff: UInt64? + + await app.completePendingRestoreActivitySeen { cutoff in + passedCutoff = cutoff + return true + } + + XCTAssertEqual(passedCutoff, restoreStartedAt) + } + + /// The suppression is armed as the restore starts, not on the Get Started tap, because startup + /// sync begins as soon as the wallet exists. + func testSuppressionFlagIsDerivedFromTheStoredStartTime() { + SettingsViewModel.shared.pendingRestoreActivitySeenSince = restoreStartedAt + XCTAssertTrue(SettingsViewModel.shared.pendingRestoreActivitySeen) + XCTAssertEqual(SettingsViewModel.shared.pendingRestoreActivitySeenSince, restoreStartedAt) + + SettingsViewModel.shared.pendingRestoreActivitySeenSince = 0 + XCTAssertFalse(SettingsViewModel.shared.pendingRestoreActivitySeen) + } } From 5720d1babfdd4a4e2984f98c6203a60258fdb418 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 24 Sep 2026 13:03:03 -0300 Subject: [PATCH 7/8] fix: skip stale and migration confirmed-only receives Port the Android confirmed-only guards from synonymdev/bitkit-android#1299: a confirmed event opens the received sheet only when its block time is within one hour of the device clock and no migration is running, so the confirmations a full wallet scan replays for old txs stay silent. Co-Authored-By: Claude Opus 5.5 (1M context) --- Bitkit/ViewModels/AppViewModel.swift | 24 ++++++++- .../ConfirmedOnlyReceiveGuardTests.swift | 54 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 BitkitTests/ConfirmedOnlyReceiveGuardTests.swift diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 310d3748d..49396f4cf 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -1219,6 +1219,22 @@ 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. + /// 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 + + static func shouldPresentConfirmedOnlyReceive( + confirmationTime: UInt64, + now: Date = Date(), + isMigrating: Bool = MigrationsService.shared.isShowingMigrationLoading || MigrationsService.shared.needsPostMigrationSync + ) -> Bool { + guard !isMigrating 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 } @@ -1398,10 +1414,14 @@ extension AppViewModel { case let .onchainTransactionReceived(txid, details): // Show notification for incoming transactions seen in the mempool presentReceivedSheetForOnchainTransaction(txid: txid, amountSats: details.amountSats) - case let .onchainTransactionConfirmed(txid, _, blockHeight, _, details): + 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 - presentReceivedSheetForOnchainTransaction(txid: txid, amountSats: details.amountSats) + if Self.shouldPresentConfirmedOnlyReceive(confirmationTime: confirmationTime) { + presentReceivedSheetForOnchainTransaction(txid: txid, amountSats: details.amountSats) + } else { + Logger.debug("Skipping received sheet for confirmed-only tx \(txid) confirmed at \(confirmationTime)") + } case let .onchainTransactionReplaced(txid, conflicts): Logger.info("Transaction replaced: \(txid) by \(conflicts.count) conflict(s)") Task { diff --git a/BitkitTests/ConfirmedOnlyReceiveGuardTests.swift b/BitkitTests/ConfirmedOnlyReceiveGuardTests.swift new file mode 100644 index 000000000..e6da7a2d8 --- /dev/null +++ b/BitkitTests/ConfirmedOnlyReceiveGuardTests.swift @@ -0,0 +1,54 @@ +@testable import Bitkit +import XCTest + +/// A confirmed event reaches the received sheet only for a recent block outside a migration, so the +/// confirmations a full wallet scan replays for old txs stay silent. #455, parity with +/// `NotifyPaymentReceivedHandler.canShowConfirmedOnly` on Android. +@MainActor +final class ConfirmedOnlyReceiveGuardTests: XCTestCase { + private let now = Date(timeIntervalSince1970: 1_790_000_000) + private let maxAge = AppViewModel.maxConfirmedOnlyReceiveAge + + private func blockTime(secondsFromNow offset: TimeInterval) -> UInt64 { + UInt64(now.timeIntervalSince1970 + offset) + } + + func testRecentConfirmationIsPresented() { + XCTAssertTrue( + AppViewModel.shouldPresentConfirmedOnlyReceive(confirmationTime: blockTime(secondsFromNow: -30), now: now, isMigrating: false) + ) + } + + func testConfirmationAtTheWindowEdgeIsPresented() { + XCTAssertTrue( + AppViewModel.shouldPresentConfirmedOnlyReceive(confirmationTime: blockTime(secondsFromNow: -maxAge), now: now, isMigrating: false) + ) + XCTAssertTrue( + AppViewModel.shouldPresentConfirmedOnlyReceive(confirmationTime: blockTime(secondsFromNow: maxAge), now: now, isMigrating: false) + ) + } + + func testOldConfirmationReplayedByAScanIsSkipped() { + XCTAssertFalse( + AppViewModel.shouldPresentConfirmedOnlyReceive( + confirmationTime: blockTime(secondsFromNow: -maxAge - 1), + now: now, + isMigrating: false + ), + "a replayed historical confirmation would pop a Received sheet" + ) + } + + func testBlockTimeFarAheadOfTheDeviceClockIsSkipped() { + XCTAssertFalse( + AppViewModel.shouldPresentConfirmedOnlyReceive(confirmationTime: blockTime(secondsFromNow: maxAge + 1), now: now, isMigrating: false) + ) + } + + func testRecentConfirmationIsSkippedDuringMigration() { + XCTAssertFalse( + AppViewModel.shouldPresentConfirmedOnlyReceive(confirmationTime: blockTime(secondsFromNow: -30), now: now, isMigrating: true), + "the post-migration scan replays confirmations for migrated txs that are not yet marked seen" + ) + } +} From 87777989b9b35f5368c38c24d960b29eef465d45 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 24 Sep 2026 13:03:03 -0300 Subject: [PATCH 8/8] docs: port onchain receive journeys Co-Authored-By: Claude Opus 5.5 (1M context) --- Bitkit/Components/CopyAddressCard.swift | 1 + journeys/README.md | 5 +++ journeys/onchain-receive/README.md | 43 +++++++++++++++++++ .../confirmed-only-received-sheet.xml | 25 +++++++++++ .../mempool-then-confirmed-single-sheet.xml | 23 ++++++++++ 5 files changed, 97 insertions(+) create mode 100644 journeys/onchain-receive/README.md create mode 100644 journeys/onchain-receive/confirmed-only-received-sheet.xml create mode 100644 journeys/onchain-receive/mempool-then-confirmed-single-sheet.xml diff --git a/Bitkit/Components/CopyAddressCard.swift b/Bitkit/Components/CopyAddressCard.swift index 4c6906248..6938f936c 100644 --- a/Bitkit/Components/CopyAddressCard.swift +++ b/Bitkit/Components/CopyAddressCard.swift @@ -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 { diff --git a/journeys/README.md b/journeys/README.md index 67db5dee3..8ed328776 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -143,6 +143,7 @@ Everything else — `N0`–`N9`, `N000`, `NDecimal`, `NRemove`, `SpendingAmount* | [widgets](widgets) | 2 | Widgets intro and add-widget flow | | [notification-permission](notification-permission) | 4 | Background-setup toggles | | [cjit-notifications](cjit-notifications) | 3 | Adapted — iOS notification copy differs from Android | +| [onchain-receive](onchain-receive) | 2 | Received sheet for mempool-first and confirmed-only deposits; the background-notification journey is not ported | | [hardware-wallet](hardware-wallet) | 16 | Trezor over Bridge; see `Docs/AI_DEVICE_TESTS.md` | | [payment-requests](payment-requests) | 2 | Linked issuer interoperability plus the ported Android resolution-failure journey | | [pubky-marketplace](pubky-marketplace) | 1 | Adapted — two-wallet Paykit marketplace payment on regtest; integration fixture required | @@ -151,6 +152,10 @@ Everything else — `N0`–`N9`, `N000`, `NDecimal`, `NRemove`, `SpendingAmount* ## Not ported +**`onchain-receive/confirmed-only-background-notification.xml`.** It covers the notification Android's +`LightningNodeService` foreground service posts for a background onchain receive. iOS has no +foreground node service, and its notification extension handles only Blocktank pushes. + **`deeplinks` (2 journeys).** The Android journeys exercise `bitkit://screen/...` routing with a dev-mode gate and a cold-start replay. iOS registers the `bitkit` URL scheme (`Bitkit/Info.plist`) and retains external URLs in `AppScene`, but `MainNavView` only routes web URLs, Pubky auth requests and callbacks, diff --git a/journeys/onchain-receive/README.md b/journeys/onchain-receive/README.md new file mode 100644 index 000000000..fc2d812c2 --- /dev/null +++ b/journeys/onchain-receive/README.md @@ -0,0 +1,43 @@ +# Onchain receive journeys + +These journeys cover the received sheet for onchain deposits (issue #455, Android #797). Ported from +`bitkit-android/journeys/onchain-receive` with synonymdev/bitkit-ios#588. + +ldk-node emits `onchainTransactionReceived` when the wallet sync finds a transaction in the mempool +and `onchainTransactionConfirmed` when it confirms. A transaction that is mined before any sync sees +it in the mempool produces only the confirmed event. `AppViewModel.handleLdkNodeEvent` routes both to +`presentReceivedSheetForOnchainTransaction`, which reserves the txid in-session and checks the +persisted seen state, so a tx shows one sheet whichever event reaches it first. + +A confirmed-only receive is shown only when its block timestamp is within one hour of the device +clock and no migration is running (`AppViewModel.shouldPresentConfirmedOnlyReceive`, matching +Android's `MAX_CONFIRMED_ONLY_AGE`). A full wallet scan replays old confirmations, which the window +keeps silent. After a seed restore, `RestoreWalletView` sets `pendingRestoreActivitySeenSince` before +the node starts, which holds every onchain received sheet until the first onchain sync completes; +that sync marks the activities that existed before the restore began as seen and clears the flag, so +the transactions it discovered stay silent when they later confirm while new deposits notify again. +Neither case can be driven on a funded device; both are covered by `ConfirmedOnlyReceiveGuardTests`, +`RestoreActivitySeenSuppressionTests` and `MarkAllUnseenActivitiesCutoffTests`. + +## Adapted from Android + +- `confirmed-only-background-notification.xml` is not ported. It covers Android's + `LightningNodeService` foreground service, which posts a "Payment Received" notification while the + app is in the background. iOS has no foreground node service and the notification extension only + handles Blocktank pushes, so there is no iOS path to drive. +- `mempool-then-confirmed-single-sheet.xml` drops the final "no Payment Received notification" check + for the same reason. +- Android skips confirmed-only receives while a backup restore runs; iOS relies on the restore hold + above, which covers the same window. + +## Preconditions + +- Onboarded regtest wallet with the node running, built with `E2E_BUILD` against the local + `bitkit-docker` stack (see the suite-wide [README](../README.md#backend-preconditions)). Fund and + mine with the `lsp` helper from the sibling Android checkout. +- Wallet sync runs every 10s. For the confirmed-only journey, run the deposit and the mine in one + shell command, then check the log: an `Onchain transaction received` line for the txid means the + sync saw the mempool first and the run tested the other path. +- The app writes its log to the app group, not `os_log`: `logs/bitkit_*.log` under + `xcrun simctl get_app_container booted to.bitkit group.bitkit`. `LightningService` logs each onchain + event as `📥 Onchain transaction received: txid=…` or `✅ Onchain transaction confirmed: txid=…`. diff --git a/journeys/onchain-receive/confirmed-only-received-sheet.xml b/journeys/onchain-receive/confirmed-only-received-sheet.xml new file mode 100644 index 000000000..e7469ea19 --- /dev/null +++ b/journeys/onchain-receive/confirmed-only-received-sheet.xml @@ -0,0 +1,25 @@ + + + Covers issue #455 (Android #797). An onchain deposit the wallet first sees already confirmed, with + no prior mempool event, must show the received sheet once. ldk-node emits + onchainTransactionConfirmed without onchainTransactionReceived in that case. + + Precondition: onboarded regtest wallet, node running, app in the foreground on the home screen. + The deposit and the mine must run in one shell command so the 10s wallet sync does not see the + transaction in the mempool first. If the log shows "Onchain transaction received" for the txid, + the run tested the mempool path instead; repeat with a new address. + + + Tap Receive (id "Receive") and verify the Receive sheet opens (id "ReceiveScreen") + Tap the "Savings" receive tab (id "Tab-savings"), tap "Show Details" (id "ShowDetails") and read the address from id "ReceiveOnchainAddress" + Swipe the Receive sheet down to return to the home screen + Run: LOG="$(ls -t "$(xcrun simctl get_app_container booted to.bitkit group.bitkit)"/logs/bitkit_*.log | head -1)" + Run in one command: ../bitkit-android/lsp POST /regtest/chain/deposit '{"address":"<savings addr>","amountSat":21797}' && ../bitkit-android/lsp POST /regtest/chain/mine '{"count":1}' + Wait up to 30s for the next wallet sync + Run: grep <txid> "$LOG" + Verify the log shows an "Onchain transaction confirmed" line for the txid and no "Onchain transaction received" line for it + Verify the received sheet (id "ReceivedTransaction") is visible with the deposited amount (id "ReceivedTransaction-primary" or "ReceivedTransaction-secondary", depending on the primary display setting) + Tap the sheet button (id "ReceivedTransactionButton") + Wait 10s and verify the received sheet does not appear again + + diff --git a/journeys/onchain-receive/mempool-then-confirmed-single-sheet.xml b/journeys/onchain-receive/mempool-then-confirmed-single-sheet.xml new file mode 100644 index 000000000..266e4f72f --- /dev/null +++ b/journeys/onchain-receive/mempool-then-confirmed-single-sheet.xml @@ -0,0 +1,23 @@ + + + Covers issue #455 (Android #797). Confirmed events now reach the received sheet, so a deposit + seen in the mempool first must not show a second sheet when it confirms. AppViewModel dedupes on + the txid in-session and on the persisted seen state across launches. + + Precondition: onboarded regtest wallet, node running, app in the foreground on the home screen. + + Adapted: the Android original ends by checking no "Payment Received" notification is posted. iOS + posts no local notification for an onchain receive, so that step is dropped. + + + Tap Receive (id "Receive") and verify the Receive sheet opens (id "ReceiveScreen") + Tap the "Savings" receive tab (id "Tab-savings"), tap "Show Details" (id "ShowDetails") and read the address from id "ReceiveOnchainAddress" + Swipe the Receive sheet down to return to the home screen + Run: ../bitkit-android/lsp POST /regtest/chain/deposit '{"address":"<savings addr>","amountSat":14797}' + Wait up to 30s and verify the received sheet (id "ReceivedTransaction") is visible with the deposited amount + Tap the sheet button (id "ReceivedTransactionButton") + Run: ../bitkit-android/lsp POST /regtest/chain/mine '{"count":1}' + Run: grep <txid> "$(ls -t "$(xcrun simctl get_app_container booted to.bitkit group.bitkit)"/logs/bitkit_*.log | head -1)" and wait until an "Onchain transaction confirmed" line shows for the txid + Wait 30s after that event and verify the received sheet does not appear again + +