diff --git a/Bitkit/Extensions/Activity+Contact.swift b/Bitkit/Extensions/Activity+Contact.swift index a2b9cb80..da84be98 100644 --- a/Bitkit/Extensions/Activity+Contact.swift +++ b/Bitkit/Extensions/Activity+Contact.swift @@ -6,6 +6,11 @@ extension Activity { return contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, contactPublicKey) }) } + func isReplacedSentTransaction(txIdsInBoostTxIds: Set) -> Bool { + guard case let .onchain(onchain) = self else { return false } + return !onchain.doesExist && onchain.txType == .sent && txIdsInBoostTxIds.contains(onchain.txId) + } + private var contactPublicKey: String? { switch self { case let .lightning(lightning): diff --git a/Bitkit/Managers/ScannerManager.swift b/Bitkit/Managers/ScannerManager.swift index 5b057e4c..824f2fa8 100644 --- a/Bitkit/Managers/ScannerManager.swift +++ b/Bitkit/Managers/ScannerManager.swift @@ -38,6 +38,9 @@ class ScannerManager: ObservableObject { } func handleScan(_ uri: String, context: ScannerContext) async { + let uri = uri.trimmingCharacters(in: .whitespacesAndNewlines) + guard !uri.isEmpty else { return } + Haptics.play(.scanSuccess) switch context { @@ -90,7 +93,7 @@ class ScannerManager: ObservableObject { } } - private func handlePubkyRouteIfNeeded(_ input: String) -> Bool { + private func handlePubkyRouteIfNeeded(_ input: String, hiding sheetId: SheetID? = .scanner, reason: String = "Scanner routed pubky key") -> Bool { guard let navigation, let route = resolvePastedPubkyRoute( input: input, @@ -101,7 +104,9 @@ class ScannerManager: ObservableObject { return false } - sheets?.hideSheetIfActive(.scanner, reason: "Scanner routed pubky key") + if let sheetId { + sheets?.hideSheetIfActive(sheetId, reason: reason) + } navigation.navigate(route) return true } @@ -115,6 +120,11 @@ class ScannerManager: ObservableObject { Haptics.play(.scanSuccess) do { + if handlePubkyRouteIfNeeded(uri, hiding: .send, reason: "Send scanner routed pubky key") { + completion(nil) + return + } + try await app.handleScannedData(uri) let route = PaymentNavigationHelper.appropriateSendRoute( @@ -177,7 +187,7 @@ class ScannerManager: ObservableObject { return } - await handleScan(uri, context: context) + await handleScan(uri.trimmingCharacters(in: .whitespacesAndNewlines), context: context) } func handleImageSelection(_ item: PhotosPickerItem?, context: ScannerContext, completion: @escaping (SendRoute?) -> Void = { _ in }) async { diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 5874326d..c740d946 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -582,6 +582,7 @@ class ActivityService { { activity.boostTxIds.append(txid) activity.isBoosted = true + activity.contact = activity.contact ?? replacedActivity?.contact activity.updatedAt = UInt64(Date().timeIntervalSince1970) try await self.update(id: activity.id, activity: .onchain(activity)) @@ -965,17 +966,25 @@ class ActivityService { func get(contact publicKey: String, sortDirection: SortDirection = .desc) async throws -> [Activity] { let normalizedKey = PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey + let txIdsInBoostTxIds = await getTxIdsInBoostTxIds() // TODO: push contact filtering into BitkitCore once the activity store exposes it. let activities = try await get(filter: .all, sortDirection: sortDirection) - return activities.filter { activity in - switch activity { - case let .lightning(lightning): - return PubkyPublicKeyFormat.matches(lightning.contact, normalizedKey) - case let .onchain(onchain): - return PubkyPublicKeyFormat.matches(onchain.contact, normalizedKey) + return activities + .filter { !isReplacedSentTransaction($0, txIdsInBoostTxIds: txIdsInBoostTxIds) } + .filter { activity in + switch activity { + case let .lightning(lightning): + return PubkyPublicKeyFormat.matches(lightning.contact, normalizedKey) + case let .onchain(onchain): + return PubkyPublicKeyFormat.matches(onchain.contact, normalizedKey) + } } - } + } + + private func isReplacedSentTransaction(_ activity: Activity, txIdsInBoostTxIds: Set) -> Bool { + guard case let .onchain(onchain) = activity else { return false } + return !onchain.doesExist && onchain.txType == .sent && txIdsInBoostTxIds.contains(onchain.txId) } func update(id: String, activity: Activity) async throws { @@ -1046,7 +1055,7 @@ class ActivityService { let normalizedContact = publicKey.map { PubkyPublicKeyFormat.normalized($0) ?? $0 } try await ServiceQueue.background(.core) { - guard let activity = try getActivityById(activityId: id) else { + guard let activity = try getActivityById(activityId: id) ?? (try? BitkitCore.getActivityByTxId(txId: id)).map(Activity.onchain) else { throw AppError(message: "Activity not found", debugMessage: "Activity with ID \(id) not found") } @@ -1055,19 +1064,49 @@ class ActivityService { guard lightning.contact != normalizedContact else { return } lightning.contact = normalizedContact lightning.updatedAt = UInt64(Date().timeIntervalSince1970) - try updateActivity(activityId: id, activity: .lightning(lightning)) + try updateActivity(activityId: lightning.id, activity: .lightning(lightning)) self.activitiesChangedSubject.send() case var .onchain(onchain): - guard onchain.contact != normalizedContact else { return } - onchain.contact = normalizedContact - onchain.updatedAt = UInt64(Date().timeIntervalSince1970) - try updateActivity(activityId: id, activity: .onchain(onchain)) - self.activitiesChangedSubject.send() + let contactChanged = onchain.contact != normalizedContact + if contactChanged { + onchain.contact = normalizedContact + onchain.updatedAt = UInt64(Date().timeIntervalSince1970) + try updateActivity(activityId: onchain.id, activity: .onchain(onchain)) + } + + let replacementContactChanged = try self.updateReplacementContactIfNeeded(for: onchain, normalizedContact: normalizedContact) + if contactChanged || replacementContactChanged { + self.activitiesChangedSubject.send() + } } } } + private func updateReplacementContactIfNeeded(for activity: OnchainActivity, normalizedContact: String?) throws -> Bool { + guard !activity.doesExist, activity.txType == .sent else { return false } + + let activities = try getActivities( + filter: .onchain, + txType: nil, + tags: nil, + search: nil, + minDate: nil, + maxDate: nil, + limit: nil, + sortDirection: nil + ) + var didUpdate = false + for case var .onchain(replacement) in activities where replacement.boostTxIds.contains(activity.txId) { + guard replacement.contact != normalizedContact else { continue } + replacement.contact = normalizedContact + replacement.updatedAt = UInt64(Date().timeIntervalSince1970) + try updateActivity(activityId: replacement.id, activity: .onchain(replacement)) + didUpdate = true + } + return didUpdate + } + func delete(id: String) async throws -> Bool { try await ServiceQueue.background(.core) { // Rebuild cache if deleting an onchain activity with boostTxIds diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index dbd6d38b..76d1d47a 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -1144,6 +1144,10 @@ extension LightningService { Logger.error(error, context: "node.eventHandled()") } + if case .channelReady = event { + await refreshChannelCache() + } + onEvent?(event) switch event { @@ -1204,7 +1208,6 @@ extension LightningService { Logger.info( "👐 Channel ready: channelId: \(channelId) userChannelId: \(userChannelId) counterpartyNodeId: \(counterpartyNodeId ?? "?") fundingTxo: \(fundingTxo != nil ? "\(fundingTxo!.txid):\(fundingTxo!.vout)" : "nil")" ) - await refreshChannelCache() case let .channelClosed(channelId, userChannelId, counterpartyNodeId, reason): let reasonString = reason.map { String(describing: $0) } ?? "" Logger.info( diff --git a/Bitkit/ViewModels/ActivityListViewModel.swift b/Bitkit/ViewModels/ActivityListViewModel.swift index 944c4473..74dcb204 100644 --- a/Bitkit/ViewModels/ActivityListViewModel.swift +++ b/Bitkit/ViewModels/ActivityListViewModel.swift @@ -273,8 +273,7 @@ class ActivityListViewModel: ObservableObject { } func contactActivities(publicKey: String) async throws -> [Activity] { - let activities = try await coreService.activity.get(contact: publicKey, sortDirection: .desc) - return await filterOutReplacedSentTransactions(activities) + try await coreService.activity.get(contact: publicKey, sortDirection: .desc) } func setContact(_ contactPublicKey: String, forPaymentId paymentId: String, syncLdkPayments: Bool = true) async throws { @@ -470,19 +469,7 @@ extension ActivityListViewModel { // Get cached set of txIds that appear in boostTxIds let txIdsInBoostTxIds = await coreService.activity.getTxIdsInBoostTxIds() - // Filter out activities that: - // 1. Are onchain - // 2. Have doesExist = false - // 3. Are sent transactions - // 4. Appear in another transaction's boostTxIds - return activities.filter { activity in - if case let .onchain(onchain) = activity { - if !onchain.doesExist && onchain.txType == .sent && txIdsInBoostTxIds.contains(onchain.txId) { - return false - } - } - return true - } + return activities.filter { !$0.isReplacedSentTransaction(txIdsInBoostTxIds: txIdsInBoostTxIds) } } /// Filter activities based on the selected tab diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 75252920..5dd91ab3 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -667,6 +667,13 @@ extension AppViewModel { return } + if PubkyPublicKeyFormat.normalized(normalized) != nil { + guard currentSequence == manualEntryValidationSequence else { return } + manualEntryValidationResult = .valid + isManualEntryInputValid = true + return + } + // Try to decode the invoice guard let decodedData = try? await decode(invoice: normalized) else { guard currentSequence == manualEntryValidationSequence else { return } diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index a2995bda..d293a33a 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -126,7 +126,7 @@ func fallbackRouteForMissingPendingImport(hasPendingImport: Bool) -> Route? { hasPendingImport ? nil : .payContacts } -func resolvePastedPubkyRoute(input: String, ownPublicKey: String?, contacts: [PubkyContact]) -> Route? { +func resolvePubkyRoute(input: String, ownPublicKey: String?, contacts: [PubkyContact]) -> Route? { guard let normalizedKey = PubkyPublicKeyFormat.normalized(input) else { return nil } @@ -142,6 +142,10 @@ func resolvePastedPubkyRoute(input: String, ownPublicKey: String?, contacts: [Pu return .addContact(publicKey: normalizedKey) } +func resolvePastedPubkyRoute(input: String, ownPublicKey: String?, contacts: [PubkyContact]) -> Route? { + resolvePubkyRoute(input: input, ownPublicKey: ownPublicKey, contacts: contacts) +} + @MainActor class NavigationViewModel: ObservableObject { @Published var path: [Route] = [] diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index ee91fee6..59850d2e 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -216,6 +216,7 @@ class WalletViewModel: ObservableObject { case .channelReady: self.bolt11 = "" Task { + await self.reconnectTrustedPeers() await self.refreshAndSyncState() try? await self.refreshBip21() } @@ -861,12 +862,20 @@ class WalletViewModel: ObservableObject { /// Sync channels and peers only private func syncChannelsAndPeers() { + let hadUsableChannels = channels?.contains(where: \.isUsable) ?? false peers = lightningService.peers channels = lightningService.channels + let hasUsableChannels = channels?.contains(where: \.isUsable) ?? false if let channels { channelCount = channels.count } + + if sharesPublicPaykitEndpoints, hasUsableChannels, !hadUsableChannels { + Task { [weak self] in + await self?.syncPublicPaykitEndpointsAfterChannelBecameUsable() + } + } } /// Sync balance details only @@ -961,7 +970,7 @@ class WalletViewModel: ObservableObject { func refreshPublicPaykitEndpoints(forceRefreshBolt11: Bool = false) async throws -> (onchainAddress: String, bolt11: String) { let publicOnchainAddress = try await refreshReusableOnchainAddress() - if hasReadyChannels { + if hasUsableChannels { let hasReusableInvoice = await hasReusablePublicPaykitInvoice() let shouldRefreshBolt11 = forceRefreshBolt11 || !hasReusableInvoice if shouldRefreshBolt11 { @@ -984,6 +993,14 @@ class WalletViewModel: ObservableObject { } } + private func syncPublicPaykitEndpointsAfterChannelBecameUsable() async { + do { + try await PublicPaykitService.syncPublishedEndpoints(wallet: self, publish: true) + } catch { + Logger.warn("Failed to refresh public Paykit endpoints after channel became usable: \(error)", context: "WalletViewModel") + } + } + private func hasReusablePublicPaykitInvoice() async -> Bool { guard !publicPaykitBolt11.isEmpty else { return false } guard publicPaykitBolt11ExpiresAt > Date().timeIntervalSince1970 + Self.publicPaykitInvoiceRefreshBufferSeconds else { return false } diff --git a/Bitkit/Views/Contacts/AddContactView.swift b/Bitkit/Views/Contacts/AddContactView.swift index 4004ad38..b88f7860 100644 --- a/Bitkit/Views/Contacts/AddContactView.swift +++ b/Bitkit/Views/Contacts/AddContactView.swift @@ -321,6 +321,7 @@ struct AddContactView: View { return false } + navigation.navigateBack() app.contactPaymentContext = ContactPaymentContext(publicKey: publicKey) sheets.showSheet(.send, data: SendConfig(view: route)) return true diff --git a/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift b/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift index 704d50b7..7a3c19d3 100644 --- a/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift +++ b/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift @@ -5,6 +5,10 @@ struct SendEnterManuallyView: View { @EnvironmentObject var wallet: WalletViewModel @EnvironmentObject var currency: CurrencyViewModel @EnvironmentObject var settings: SettingsViewModel + @EnvironmentObject var contactsManager: ContactsManager + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var pubkyProfile: PubkyProfileManager + @EnvironmentObject var sheets: SheetViewModel @Binding var navigationPath: [SendRoute] @FocusState private var isTextEditorFocused: Bool @@ -77,6 +81,16 @@ struct SendEnterManuallyView: View { guard !uri.isEmpty, app.isManualEntryInputValid else { return } + if let route = resolvePubkyRoute( + input: uri, + ownPublicKey: pubkyProfile.publicKey, + contacts: contactsManager.contacts + ) { + sheets.hideSheetIfActive(.send, reason: "Manual pubky entry routed to contacts") + navigation.navigate(route) + return + } + do { wallet.resetSendState(speed: settings.defaultTransactionSpeed) @@ -106,5 +120,11 @@ struct SendEnterManuallyView: View { SendEnterManuallyView(navigationPath: .constant([])) .environmentObject(AppViewModel()) .environmentObject(WalletViewModel()) + .environmentObject(CurrencyViewModel()) + .environmentObject(SettingsViewModel.shared) + .environmentObject(ContactsManager()) + .environmentObject(NavigationViewModel()) + .environmentObject(PubkyProfileManager()) + .environmentObject(SheetViewModel()) .preferredColorScheme(.dark) } diff --git a/Bitkit/Views/Wallets/Send/SendOptionsView.swift b/Bitkit/Views/Wallets/Send/SendOptionsView.swift index 07cac997..64904d40 100644 --- a/Bitkit/Views/Wallets/Send/SendOptionsView.swift +++ b/Bitkit/Views/Wallets/Send/SendOptionsView.swift @@ -3,9 +3,13 @@ import SwiftUI struct SendOptionsView: View { @EnvironmentObject var app: AppViewModel + @EnvironmentObject var contactsManager: ContactsManager @EnvironmentObject var currency: CurrencyViewModel + @EnvironmentObject var navigation: NavigationViewModel + @EnvironmentObject var pubkyProfile: PubkyProfileManager @EnvironmentObject var scanner: ScannerManager @EnvironmentObject var settings: SettingsViewModel + @EnvironmentObject var sheets: SheetViewModel @EnvironmentObject var wallet: WalletViewModel @Binding var navigationPath: [SendRoute] @@ -71,8 +75,12 @@ struct SendOptionsView: View { wallet.syncState() scanner.configure( app: app, + contactsManager: contactsManager, currency: currency, - settings: settings + settings: settings, + navigation: navigation, + pubkyProfile: pubkyProfile, + sheets: sheets ) } } @@ -110,6 +118,13 @@ struct SendOptionsView: View { NavigationStack { SendOptionsView(navigationPath: .constant([])) .environmentObject(AppViewModel()) + .environmentObject(ContactsManager()) + .environmentObject(CurrencyViewModel()) + .environmentObject(NavigationViewModel()) + .environmentObject(PubkyProfileManager()) + .environmentObject(ScannerManager()) + .environmentObject(SettingsViewModel.shared) + .environmentObject(SheetViewModel()) .environmentObject(WalletViewModel()) } .presentationDetents([.height(UIScreen.screenHeight - 120)]) diff --git a/BitkitTests/ActivityListTest.swift b/BitkitTests/ActivityListTest.swift index 989b68eb..3f065ddb 100644 --- a/BitkitTests/ActivityListTest.swift +++ b/BitkitTests/ActivityListTest.swift @@ -353,6 +353,93 @@ final class ActivityTests: XCTestCase { } } + func testSetContactPropagatesToReplacementTransaction() async throws { + let contactPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let replacedTxId = "test-replaced-txid" + let replacementTxId = "test-replacement-txid" + + try await service.insert( + makeOnchainActivity( + id: replacedTxId, + txId: replacedTxId, + doesExist: false, + contact: nil + ) + ) + try await service.insert( + makeOnchainActivity( + id: replacementTxId, + txId: replacementTxId, + boostTxIds: [replacedTxId], + contact: nil + ) + ) + + try await service.setContact(contactPublicKey, forActivity: replacedTxId) + + guard case let .some(.onchain(replacedActivity)) = try await service.getActivity(id: replacedTxId), + case let .some(.onchain(replacementActivity)) = try await service.getActivity(id: replacementTxId) + else { + return XCTFail("Expected onchain activities") + } + + XCTAssertEqual(replacedActivity.contact, contactPublicKey) + XCTAssertEqual(replacementActivity.contact, contactPublicKey) + } + + func testSetContactFindsOnchainActivityByTxid() async throws { + let contactPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let activityId = "test-onchain-activity-id" + let txId = "test-onchain-txid" + + try await service.insert( + makeOnchainActivity( + id: activityId, + txId: txId, + contact: nil + ) + ) + + try await service.setContact(contactPublicKey, forActivity: txId) + + guard case let .some(.onchain(activity)) = try await service.getActivity(id: activityId) else { + return XCTFail("Expected onchain activity") + } + + XCTAssertEqual(activity.contact, contactPublicKey) + } + + func testGetContactActivitiesFiltersReplacedSentTransaction() async throws { + let contactPublicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + let replacedTxId = "test-contact-replaced-txid" + let replacementTxId = "test-contact-replacement-txid" + + try await service.insert( + makeOnchainActivity( + id: replacedTxId, + txId: replacedTxId, + doesExist: false, + contact: contactPublicKey + ) + ) + try await service.insert( + makeOnchainActivity( + id: replacementTxId, + txId: replacementTxId, + boostTxIds: [replacedTxId], + contact: contactPublicKey + ) + ) + + let activities = try await service.get(contact: contactPublicKey) + + XCTAssertEqual(activities.count, 1) + guard case let .some(.onchain(activity)) = activities.first else { + return XCTFail("Expected replacement onchain activity") + } + XCTAssertEqual(activity.txId, replacementTxId) + } + func testDeleteActivity() async throws { let timestamp = UInt64(Date().timeIntervalSince1970) @@ -468,4 +555,38 @@ final class ActivityTests: XCTestCase { let allActivities = try await service.get(filter: .all) XCTAssertEqual(allActivities.count, 3) } + + private func makeOnchainActivity( + id: String, + txId: String, + boostTxIds: [String] = [], + doesExist: Bool = true, + contact: String? + ) -> Activity { + let timestamp = UInt64(Date().timeIntervalSince1970) + return Activity.onchain( + OnchainActivity( + id: id, + txType: .sent, + txId: txId, + value: 1000, + fee: 10, + feeRate: 1, + address: "bcrt1...", + confirmed: false, + timestamp: timestamp, + isBoosted: !boostTxIds.isEmpty, + boostTxIds: boostTxIds, + isTransfer: false, + doesExist: doesExist, + confirmTimestamp: nil, + channelId: nil, + transferTxId: nil, + contact: contact, + createdAt: nil, + updatedAt: nil, + seenAt: nil + ) + ) + } } diff --git a/BitkitTests/ContactsManagerTests.swift b/BitkitTests/ContactsManagerTests.swift index a67909ad..86256d95 100644 --- a/BitkitTests/ContactsManagerTests.swift +++ b/BitkitTests/ContactsManagerTests.swift @@ -80,6 +80,37 @@ final class ContactsManagerTests: XCTestCase { XCTAssertEqual(activity.contact(in: [contact])?.publicKey, contact.publicKey) } + func testActivityDetectsReplacedSentTransaction() { + let replacedTxId = "replaced_tx_id" + let activity = Activity.onchain( + OnchainActivity( + id: replacedTxId, + txType: .sent, + txId: replacedTxId, + value: 1000, + fee: 10, + feeRate: 1, + address: "bcrt1...", + confirmed: false, + timestamp: 0, + isBoosted: false, + boostTxIds: [], + isTransfer: false, + doesExist: false, + confirmTimestamp: nil, + channelId: nil, + transferTxId: nil, + contact: nil, + createdAt: nil, + updatedAt: nil, + seenAt: nil + ) + ) + + XCTAssertTrue(activity.isReplacedSentTransaction(txIdsInBoostTxIds: [replacedTxId])) + XCTAssertFalse(activity.isReplacedSentTransaction(txIdsInBoostTxIds: ["other_tx_id"])) + } + func testResolveAddContactValidationReturnsEmptyForBlankInput() { XCTAssertEqual(resolveAddContactValidation(input: " ", ownPublicKey: nil), .empty) } @@ -250,6 +281,19 @@ final class ContactsManagerTests: XCTestCase { ) } + func testResolvePastedPubkyRouteTrimsClipboardInput() { + let contactKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" + + XCTAssertEqual( + resolvePastedPubkyRoute( + input: " \(contactKey)\n", + ownPublicKey: nil, + contacts: [makeContact(publicKey: contactKey)] + ), + .contactDetail(publicKey: contactKey) + ) + } + func testResolvePastedPubkyRouteReturnsAddContactForUnknownKey() { let contactKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg" diff --git a/changelog.d/next/539.fixed.md b/changelog.d/next/539.fixed.md new file mode 100644 index 00000000..456ab55d --- /dev/null +++ b/changelog.d/next/539.fixed.md @@ -0,0 +1 @@ +Improved public contact payment flows for manual Pubky entry, RBF activity display, and newly opened Lightning channels.