From 38417b4c99985bf1f149d77e4c7cf3ec71481b87 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 6 Nov 2025 09:52:00 -0300 Subject: [PATCH 01/20] chore: add forceClose parameter --- Bitkit/Services/LightningService.swift | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index eec2c9ba3..98c779be8 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -377,16 +377,30 @@ class LightningService { } } - func closeChannel(_ channel: ChannelDetails) async throws { + func closeChannel(_ channel: ChannelDetails, force: Bool = false, forceCloseReason: String? = nil) async throws { guard let node else { throw AppError(serviceError: .nodeNotStarted) } + let channelId = channel.channelId + return try await ServiceQueue.background(.ldk) { - try node.closeChannel( - userChannelId: channel.userChannelId, - counterpartyNodeId: channel.counterpartyNodeId - ) + Logger.debug("Initiating channel close (force=\(force)): '\(channelId)'", context: "LightningService") + + if force { + try node.forceCloseChannel( + userChannelId: channel.userChannelId, + counterpartyNodeId: channel.counterpartyNodeId, + broadcastLatestTxn: forceCloseReason ?? "" // TODO: CHECK + ) + } else { + try node.closeChannel( + userChannelId: channel.userChannelId, + counterpartyNodeId: channel.counterpartyNodeId + ) + } + + Logger.info("Channel close initiated (force=\(force)): '\(channelId)'", context: "LightningService") } } From 2c61e60b4625c005ed9e4fb43010c8ce1eb5f5ab Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 6 Nov 2025 13:12:35 -0300 Subject: [PATCH 02/20] feat: force transfer sheet and action --- Bitkit/AppScene.swift | 2 +- Bitkit/MainNavView.swift | 8 +++ Bitkit/ViewModels/SheetViewModel.swift | 13 +++++ Bitkit/ViewModels/TransferViewModel.swift | 41 ++++++++++++- Bitkit/Views/Sheets/ForceTransferSheet.swift | 61 ++++++++++++++++++++ 5 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 Bitkit/Views/Sheets/ForceTransferSheet.swift diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index d6dc9f886..40262df4a 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -49,7 +49,7 @@ struct AppScene: View { _currency = StateObject(wrappedValue: CurrencyViewModel()) _blocktank = StateObject(wrappedValue: BlocktankViewModel()) _activity = StateObject(wrappedValue: ActivityListViewModel(transferService: transferService)) - _transfer = StateObject(wrappedValue: TransferViewModel(transferService: transferService)) + _transfer = StateObject(wrappedValue: TransferViewModel(transferService: transferService, sheetViewModel: sheetViewModel)) _widgets = StateObject(wrappedValue: WidgetsViewModel()) _settings = StateObject(wrappedValue: SettingsViewModel()) diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 5fb8ca4c0..dcbd4daae 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -139,6 +139,14 @@ struct MainNavView: View { ) { config in SendSheet(config: config) } + .sheet( + item: $sheets.forceTransferSheetItem, + onDismiss: { + sheets.hideSheet() + } + ) { + config in ForceTransferSheet(config: config) + } .accentColor(.white) .overlay { TabBar() diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index c20533e9f..2e2d37b8d 100644 --- a/Bitkit/ViewModels/SheetViewModel.swift +++ b/Bitkit/ViewModels/SheetViewModel.swift @@ -5,6 +5,7 @@ enum SheetID: String, CaseIterable { case appUpdate case backup case boost + case forceTransfer case forgotPin case gift case highBalance @@ -287,4 +288,16 @@ class SheetViewModel: ObservableObject { } } } + + var forceTransferSheetItem: ForceTransferSheetItem? { + get { + guard let config = activeSheetConfiguration, config.id == .forceTransfer else { return nil } + return ForceTransferSheetItem() + } + set { + if newValue == nil { + activeSheetConfiguration = nil + } + } + } } diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 99dd25d88..b6d617a7c 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -27,6 +27,7 @@ class TransferViewModel: ObservableObject { private let lightningService: LightningService private let currencyService: CurrencyService private let transferService: TransferService + private weak var sheetViewModel: SheetViewModel? private var refreshTimer: Timer? private var refreshTask: Task? @@ -39,12 +40,14 @@ class TransferViewModel: ObservableObject { coreService: CoreService = .shared, lightningService: LightningService = .shared, currencyService: CurrencyService = .shared, - transferService: TransferService + transferService: TransferService, + sheetViewModel: SheetViewModel? = nil ) { self.coreService = coreService self.lightningService = lightningService self.currencyService = currencyService self.transferService = transferService + self.sheetViewModel = sheetViewModel } /// Convenience initializer for testing and previews @@ -605,9 +608,41 @@ class TransferViewModel: ObservableObject { try? await Task.sleep(nanoseconds: UInt64(retryInterval * 1_000_000_000)) } - Logger.info("Giving up on coop close.") - // TODO: Show force transfer UI + Logger.info("Giving up on coop close. Showing force transfer UI.") + + // Show force transfer sheet + sheetViewModel?.showSheet(.forceTransfer) + } + } + + /// Force close all channels that failed to cooperatively close + func forceCloseChannel() async throws { + guard !channelsToClose.isEmpty else { + Logger.warning("No channels to force close") + return + } + + Logger.info("Force closing \(channelsToClose.count) channel(s)") + + for channel in channelsToClose { + do { + try await lightningService.closeChannel( + channel, + force: true, + forceCloseReason: "User requested force close after cooperative close failed" + ) + Logger.info("Successfully initiated force close for channel: \(channel.channelId)") + } catch { + Logger.error("Failed to force close channel: \(channel.channelId)", context: error.localizedDescription) + throw error + } } + + // Clear the channels to close list after force closing + channelsToClose = [] + + // Sync transfer states + try? await transferService.syncTransferStates() } } diff --git a/Bitkit/Views/Sheets/ForceTransferSheet.swift b/Bitkit/Views/Sheets/ForceTransferSheet.swift new file mode 100644 index 000000000..ccd4dce15 --- /dev/null +++ b/Bitkit/Views/Sheets/ForceTransferSheet.swift @@ -0,0 +1,61 @@ +import SwiftUI + +struct ForceTransferSheetItem: SheetItem { + let id: SheetID = .forceTransfer + let size: SheetSize = .large +} + +struct ForceTransferSheet: View { + @EnvironmentObject private var app: AppViewModel + @EnvironmentObject private var sheets: SheetViewModel + @EnvironmentObject private var transfer: TransferViewModel + let config: ForceTransferSheetItem + + @State private var isLoading = false + + var body: some View { + Sheet(id: .forceTransfer, data: config) { + SheetIntro( + navTitle: t("lightning__force_nav_title"), + title: t("lightning__force_title"), + description: t("lightning__force_text"), + image: "exclamation-mark", + continueText: t("lightning__force_button"), + cancelText: t("common__cancel"), + accentColor: .yellowAccent, + accentFont: Fonts.bold, + testID: "ForceTransferSheet", + onCancel: onCancel, + onContinue: onForceTransfer + ) + } + } + + private func onCancel() { + sheets.hideSheet() + } + + private func onForceTransfer() { + isLoading = true + + Task { @MainActor in + do { + try await transfer.forceCloseChannel() + sheets.hideSheet() + app.toast( + type: .success, + title: t("lightning__force_init_title"), + description: t("lightning__force_init_msg") + ) + } catch { + Logger.error("Force transfer failed", context: error.localizedDescription) + app.toast( + type: .error, + title: t("lightning__force_failed_title"), + description: t("lightning__force_failed_msg") + ) + } + isLoading = false + } + } +} From 0c61e10a111677a3f71d870073e7d5aeeeab9dd4 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 6 Nov 2025 14:06:02 -0300 Subject: [PATCH 03/20] fix: parameter name --- Bitkit/Services/LightningService.swift | 2 +- Bitkit/ViewModels/TransferViewModel.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index b238b7aa5..ec01b368f 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -391,7 +391,7 @@ class LightningService { try node.forceCloseChannel( userChannelId: channel.userChannelId, counterpartyNodeId: channel.counterpartyNodeId, - broadcastLatestTxn: forceCloseReason ?? "" // TODO: CHECK + reason: forceCloseReason ?? "" ) } else { try node.closeChannel( diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index b6d617a7c..081c69f66 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -618,7 +618,7 @@ class TransferViewModel: ObservableObject { /// Force close all channels that failed to cooperatively close func forceCloseChannel() async throws { guard !channelsToClose.isEmpty else { - Logger.warning("No channels to force close") + Logger.warn("No channels to force close") return } From a8a927f1585ccc46bd11d3cda3bfab7ae14212bd Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 6 Nov 2025 14:46:41 -0300 Subject: [PATCH 04/20] chore: remove redundant variable --- Bitkit/Services/LightningService.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index ec01b368f..427b3c810 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -382,10 +382,8 @@ class LightningService { throw AppError(serviceError: .nodeNotStarted) } - let channelId = channel.channelId - return try await ServiceQueue.background(.ldk) { - Logger.debug("Initiating channel close (force=\(force)): '\(channelId)'", context: "LightningService") + Logger.debug("Initiating channel close (force=\(force)): '\(channel.channelId)'", context: "LightningService") if force { try node.forceCloseChannel( From 7a5c6cf20504dbef892ed1972f003431d6623009 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 6 Nov 2025 14:54:20 -0300 Subject: [PATCH 05/20] fix: agregate errors and throw an agregated list --- Bitkit/ViewModels/TransferViewModel.swift | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 081c69f66..0112a685e 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -624,6 +624,9 @@ class TransferViewModel: ObservableObject { Logger.info("Force closing \(channelsToClose.count) channel(s)") + var errors: [(channelId: String, error: Error)] = [] + var successfulChannels: [ChannelDetails] = [] + for channel in channelsToClose { do { try await lightningService.closeChannel( @@ -632,17 +635,28 @@ class TransferViewModel: ObservableObject { forceCloseReason: "User requested force close after cooperative close failed" ) Logger.info("Successfully initiated force close for channel: \(channel.channelId)") + successfulChannels.append(channel) } catch { Logger.error("Failed to force close channel: \(channel.channelId)", context: error.localizedDescription) - throw error + errors.append((channelId: channel.channelId, error: error)) } } - // Clear the channels to close list after force closing - channelsToClose = [] + // Remove successfully closed channels from the list + channelsToClose.removeAll { channel in + successfulChannels.contains { $0.channelId == channel.channelId } + } - // Sync transfer states try? await transferService.syncTransferStates() + + // If any errors occurred, throw an aggregated error + if !errors.isEmpty { + let errorMessages = errors.map { "\($0.channelId): \($0.error.localizedDescription)" }.joined(separator: ", ") + throw AppError( + message: "Failed to force close \(errors.count) of \(errors.count + successfulChannels.count) channel(s)", + debugMessage: errorMessages + ) + } } } From 9cc4ab8a203d182ff717544cb019f02a4ce4171f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 6 Nov 2025 15:05:10 -0300 Subject: [PATCH 06/20] chore: remove weak reference --- Bitkit/ViewModels/TransferViewModel.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 0112a685e..df426334e 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -27,7 +27,7 @@ class TransferViewModel: ObservableObject { private let lightningService: LightningService private let currencyService: CurrencyService private let transferService: TransferService - private weak var sheetViewModel: SheetViewModel? + private let sheetViewModel: SheetViewModel private var refreshTimer: Timer? private var refreshTask: Task? @@ -41,7 +41,7 @@ class TransferViewModel: ObservableObject { lightningService: LightningService = .shared, currencyService: CurrencyService = .shared, transferService: TransferService, - sheetViewModel: SheetViewModel? = nil + sheetViewModel: SheetViewModel ) { self.coreService = coreService self.lightningService = lightningService @@ -54,7 +54,8 @@ class TransferViewModel: ObservableObject { convenience init( coreService: CoreService = .shared, lightningService: LightningService = .shared, - currencyService: CurrencyService = .shared + currencyService: CurrencyService = .shared, + sheetViewModel: SheetViewModel = SheetViewModel() ) { let transferService = TransferService( lightningService: lightningService, @@ -64,7 +65,8 @@ class TransferViewModel: ObservableObject { coreService: coreService, lightningService: lightningService, currencyService: currencyService, - transferService: transferService + transferService: transferService, + sheetViewModel: sheetViewModel ) } @@ -611,7 +613,7 @@ class TransferViewModel: ObservableObject { Logger.info("Giving up on coop close. Showing force transfer UI.") // Show force transfer sheet - sheetViewModel?.showSheet(.forceTransfer) + sheetViewModel.showSheet(.forceTransfer) } } From 354976cfe1183e8275f48b1a9ae6e00a825bf0bf Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 6 Nov 2025 17:15:24 -0300 Subject: [PATCH 07/20] chore: remove log --- Bitkit/Services/LightningService.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index f5fe58ad7..dbbd26ca7 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -414,8 +414,6 @@ class LightningService { counterpartyNodeId: channel.counterpartyNodeId ) } - - Logger.info("Channel close initiated (force=\(force)): '\(channelId)'", context: "LightningService") } } From a017499b5a8c2ccb433d7c450b08ec3bebcdcf9e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 10 Nov 2025 09:11:43 -0300 Subject: [PATCH 08/20] feat: metadata model --- Bitkit/Models/TransactionMetadata.swift | 41 +++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 Bitkit/Models/TransactionMetadata.swift diff --git a/Bitkit/Models/TransactionMetadata.swift b/Bitkit/Models/TransactionMetadata.swift new file mode 100644 index 000000000..bda499959 --- /dev/null +++ b/Bitkit/Models/TransactionMetadata.swift @@ -0,0 +1,41 @@ +import Foundation + +/// Metadata for onchain transactions that needs to be temporarily stored +/// until it can be applied to activities during sync operations +struct TransactionMetadata: Codable, Identifiable { + /// Transaction ID (also serves as the unique identifier) + let txId: String + + /// Fee rate in satoshis per vbyte + let feeRate: UInt64 + + /// Destination address + let address: String + + /// Whether this transaction is a transfer between wallets (e.g., channel funding) + let isTransfer: Bool + + /// Associated channel ID for channel funding transactions + let channelId: String? + + /// Timestamp when this metadata was created (for cleanup purposes) + let createdAt: UInt64 + + var id: String { txId } + + init( + txId: String, + feeRate: UInt64, + address: String, + isTransfer: Bool, + channelId: String? = nil, + createdAt: UInt64 + ) { + self.txId = txId + self.feeRate = feeRate + self.address = address + self.isTransfer = isTransfer + self.channelId = channelId + self.createdAt = createdAt + } +} From 386d12f9b3f361a2aafd1e02edd164ea2f690b18 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 10 Nov 2025 09:14:47 -0300 Subject: [PATCH 09/20] feat: create store class --- .../Services/TransactionMetadataStorage.swift | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 Bitkit/Services/TransactionMetadataStorage.swift diff --git a/Bitkit/Services/TransactionMetadataStorage.swift b/Bitkit/Services/TransactionMetadataStorage.swift new file mode 100644 index 000000000..835a4d5e0 --- /dev/null +++ b/Bitkit/Services/TransactionMetadataStorage.swift @@ -0,0 +1,120 @@ +import Combine +import Foundation + +/// Handles persistence of TransactionMetadata objects using UserDefaults +/// Metadata is temporarily stored until it can be applied to activities during sync +class TransactionMetadataStorage { + static let shared = TransactionMetadataStorage() + + private let defaults: UserDefaults + private let metadataKey = "transactionMetadata" + + private let metadataChangedSubject = PassthroughSubject() + + var metadataChangedPublisher: AnyPublisher { + metadataChangedSubject.eraseToAnyPublisher() + } + + private init(suiteName: String? = nil) { + if let suiteName { + defaults = UserDefaults(suiteName: suiteName) ?? .standard + } else { + defaults = .standard + } + } + + /// Insert a new transaction metadata entry + func insert(_ metadata: TransactionMetadata) throws { + var allMetadata = try getAll() + + // Check if metadata for this txId already exists + if allMetadata.contains(where: { $0.txId == metadata.txId }) { + Logger.warn("Transaction metadata for \(metadata.txId) already exists, skipping insert", context: "TransactionMetadataStorage") + return + } + + allMetadata.append(metadata) + try save(allMetadata) + Logger.info("Inserted transaction metadata: txId=\(metadata.txId)", context: "TransactionMetadataStorage") + metadataChangedSubject.send() + } + + /// Insert a list of transaction metadata entries (for restore operations) + func insertList(_ metadataList: [TransactionMetadata]) throws { + var allMetadata = try getAll() + var hasChanges = false + + for metadata in metadataList { + // Only insert if not already present + if !allMetadata.contains(where: { $0.txId == metadata.txId }) { + allMetadata.append(metadata) + hasChanges = true + } + } + + if hasChanges { + try save(allMetadata) + Logger.info("Inserted \(metadataList.count) transaction metadata entries", context: "TransactionMetadataStorage") + metadataChangedSubject.send() + } + } + + /// Get all stored transaction metadata + func getAll() throws -> [TransactionMetadata] { + guard let data = defaults.data(forKey: metadataKey) else { + return [] + } + + let decoder = JSONDecoder() + return try decoder.decode([TransactionMetadata].self, from: data) + } + + /// Remove metadata by transaction ID + func remove(txId: String) throws { + var allMetadata = try getAll() + let originalCount = allMetadata.count + + allMetadata.removeAll { $0.txId == txId } + + if allMetadata.count != originalCount { + try save(allMetadata) + Logger.info("Removed transaction metadata: txId=\(txId)", context: "TransactionMetadataStorage") + metadataChangedSubject.send() + } + } + + /// Remove all transaction metadata (for testing or cleanup) + func removeAll() throws { + let allMetadata = try getAll() + + if allMetadata.isEmpty { + return + } + + defaults.removeObject(forKey: metadataKey) + Logger.info("Removed all transaction metadata (\(allMetadata.count) entries)", context: "TransactionMetadataStorage") + metadataChangedSubject.send() + } + + /// Remove old metadata entries that are older than the specified timestamp + func removeOld(olderThan timestamp: UInt64) throws { + var allMetadata = try getAll() + let originalCount = allMetadata.count + + allMetadata.removeAll { $0.createdAt < timestamp } + + if allMetadata.count != originalCount { + try save(allMetadata) + Logger.info("Removed \(originalCount - allMetadata.count) old transaction metadata entries", context: "TransactionMetadataStorage") + metadataChangedSubject.send() + } + } + + // MARK: - Private Helpers + + private func save(_ metadata: [TransactionMetadata]) throws { + let encoder = JSONEncoder() + let data = try encoder.encode(metadata) + defaults.set(data, forKey: metadataKey) + } +} From f5846f3fc85e5afd14611e7c412d941539be9327 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 10 Nov 2025 09:19:56 -0300 Subject: [PATCH 10/20] feat: save metadata on send method --- Bitkit/ViewModels/WalletViewModel.swift | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 53368dfcc..7f43ccfac 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -232,9 +232,10 @@ class WalletViewModel: ObservableObject { /// - address: The bitcoin address to send to /// - sats: The amount in satoshis to send /// - isMaxAmount: Whether this is a max amount send (uses sendAllToAddress) + /// - isTransfer: Whether this is a transfer between wallets (e.g., channel funding) /// - Returns: The transaction ID (txid) of the sent transaction /// - Throws: An error if the transaction fails or if fee rates cannot be retrieved - func send(address: String, sats: UInt64, isMaxAmount: Bool = false) async throws -> Txid { + func send(address: String, sats: UInt64, isMaxAmount: Bool = false, isTransfer: Bool = false) async throws -> Txid { guard let selectedFeeRateSatsPerVByte else { throw AppError(message: "Fee rate not set", debugMessage: "Please set a fee rate before selecting UTXOs.") } @@ -253,6 +254,18 @@ class WalletViewModel: ObservableObject { isMaxAmount: isMaxAmount ) + // Capture transaction metadata for later activity update + let metadata = TransactionMetadata( + txId: txid, + feeRate: UInt64(selectedFeeRateSatsPerVByte), + address: address, + isTransfer: isTransfer, + channelId: nil, + createdAt: UInt64(Date().timeIntervalSince1970) + ) + try? TransactionMetadataStorage.shared.insert(metadata) + Logger.debug("Captured transaction metadata for txid: \(txid), isTransfer: \(isTransfer)", context: "WalletViewModel") + Task { // Best to auto sync on chain so we have latest state try await sync() From 7cb329d8a32a3e7fe7205f6a20cd07f1fb1dd555 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 10 Nov 2025 09:37:25 -0300 Subject: [PATCH 11/20] refactor: move metadata saving to LightningService --- Bitkit/Services/LightningService.swift | 24 ++++++++++++++++++++--- Bitkit/ViewModels/TransferViewModel.swift | 7 ++++++- Bitkit/ViewModels/WalletViewModel.swift | 15 ++------------ 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index bfbb8fe4f..719d94f4d 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -350,16 +350,20 @@ class LightningService { sats: UInt64, satsPerVbyte: UInt32, utxosToSpend: [SpendableUtxo]? = nil, - isMaxAmount: Bool = false + isMaxAmount: Bool = false, + isTransfer: Bool = false ) async throws -> Txid { guard let node else { throw AppError(serviceError: .nodeNotSetup) } - Logger.info("Sending \(sats) sats to \(address) with fee rate \(satsPerVbyte) sats/vbyte (isMaxAmount: \(isMaxAmount))") + Logger + .info( + "Sending \(sats) sats to \(address) with fee rate \(satsPerVbyte) sats/vbyte (isMaxAmount: \(isMaxAmount), isTransfer: \(isTransfer))" + ) do { - return try await ServiceQueue.background(.ldk) { + let txid = try await ServiceQueue.background(.ldk) { if isMaxAmount { // For max amount sends, use sendAllToAddress to send all available funds try node.onchainPayment().sendAllToAddress( @@ -377,6 +381,20 @@ class LightningService { ) } } + + // Capture transaction metadata for later activity update + let metadata = TransactionMetadata( + txId: txid, + feeRate: UInt64(satsPerVbyte), + address: address, + isTransfer: isTransfer, + channelId: nil, + createdAt: UInt64(Date().timeIntervalSince1970) + ) + try? TransactionMetadataStorage.shared.insert(metadata) + Logger.debug("Captured transaction metadata for txid: \(txid), isTransfer: \(isTransfer)", context: "LightningService") + + return txid } catch { dumpLdkLogs() throw error diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index df426334e..8f2ff0f30 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -130,7 +130,12 @@ class TransferViewModel: ObservableObject { throw AppError(message: "Order payment onchain address is nil", debugMessage: nil) } - let txid = try await lightningService.send(address: address, sats: order.feeSat, satsPerVbyte: satsPerVbyte) + let txid = try await lightningService.send( + address: address, + sats: order.feeSat, + satsPerVbyte: satsPerVbyte, + isTransfer: true + ) // Create transfer tracking record for spending do { diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 7f43ccfac..0ae30c412 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -251,21 +251,10 @@ class WalletViewModel: ObservableObject { sats: sats, satsPerVbyte: selectedFeeRateSatsPerVByte, utxosToSpend: selectedUtxos, - isMaxAmount: isMaxAmount + isMaxAmount: isMaxAmount, + isTransfer: isTransfer ) - // Capture transaction metadata for later activity update - let metadata = TransactionMetadata( - txId: txid, - feeRate: UInt64(selectedFeeRateSatsPerVByte), - address: address, - isTransfer: isTransfer, - channelId: nil, - createdAt: UInt64(Date().timeIntervalSince1970) - ) - try? TransactionMetadataStorage.shared.insert(metadata) - Logger.debug("Captured transaction metadata for txid: \(txid), isTransfer: \(isTransfer)", context: "WalletViewModel") - Task { // Best to auto sync on chain so we have latest state try await sync() From 8881e51239e04ce10c80e841385b8c055661536d Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 10 Nov 2025 09:45:31 -0300 Subject: [PATCH 12/20] chore: create updateActivityMetadata method --- Bitkit/Services/CoreService.swift | 71 +++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index ae7d0be9c..866cf75bf 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -235,6 +235,77 @@ class ActivityService { } } + func updateActivitiesMetadata() async throws { + let allMetadata = try TransactionMetadataStorage.shared.getAll() + + guard !allMetadata.isEmpty else { + Logger.debug("No transaction metadata to update", context: "CoreService.updateActivitiesMetadata") + return + } + + try await ServiceQueue.background(.core) { + Logger.info("Updating activities with \(allMetadata.count) metadata entries", context: "CoreService.updateActivitiesMetadata") + + var updatedCount = 0 + var removedCount = 0 + + for metadata in allMetadata { + do { + // Find activity by txId + guard let activity = try getActivityById(activityId: metadata.txId) else { + Logger.debug( + "Activity not found for txId: \(metadata.txId), keeping metadata for next sync", + context: "CoreService.updateActivitiesMetadata" + ) + continue + } + + // Only update onchain activities + guard case var .onchain(onchainActivity) = activity else { + Logger.debug("Activity \(metadata.txId) is not onchain, skipping", context: "CoreService.updateActivitiesMetadata") + // Remove metadata since it won't be applicable + try? TransactionMetadataStorage.shared.remove(txId: metadata.txId) + removedCount += 1 + continue + } + + // Update with metadata + onchainActivity.feeRate = metadata.feeRate + onchainActivity.address = metadata.address + onchainActivity.isTransfer = metadata.isTransfer + onchainActivity.channelId = metadata.channelId + + // Update transferTxId if this is a transfer + if metadata.isTransfer { + onchainActivity.transferTxId = metadata.txId + } + + onchainActivity.updatedAt = UInt64(Date().timeIntervalSince1970) + + // Save updated activity + try updateActivity(activityId: metadata.txId, activity: .onchain(onchainActivity)) + updatedCount += 1 + + // Remove metadata after successful update + try? TransactionMetadataStorage.shared.remove(txId: metadata.txId) + removedCount += 1 + + Logger.debug("Updated activity with metadata: \(metadata.txId)", context: "CoreService.updateActivitiesMetadata") + } catch { + Logger.error("Failed to update activity metadata for \(metadata.txId): \(error)", context: "CoreService.updateActivitiesMetadata") + } + } + + if updatedCount > 0 { + Logger.info( + "Updated \(updatedCount) activities with metadata, removed \(removedCount) metadata entries", + context: "CoreService.updateActivitiesMetadata" + ) + self.activitiesChangedSubject.send() + } + } + } + func getActivity(id: String) async throws -> Activity? { try await ServiceQueue.background(.core) { try getActivityById(activityId: id) From 77c1905bb82103198895dc7eb69980af2dd122e6 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Mon, 10 Nov 2025 09:47:06 -0300 Subject: [PATCH 13/20] feat: Update activities with saved metadata after syncing payments --- Bitkit/Services/CoreService.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 866cf75bf..2c3f15ecb 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -233,6 +233,8 @@ class ActivityService { Logger.info("Synced LDK payments - Added: \(addedCount) - Updated: \(updatedCount)", context: "CoreService") self.activitiesChangedSubject.send() } + + try await updateActivitiesMetadata() } func updateActivitiesMetadata() async throws { From 4c15d1171454fe1ce32f8bd6e3c81238ccf3e066 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 12 Nov 2025 09:51:19 -0300 Subject: [PATCH 14/20] chore: add logs --- Bitkit/Services/CoreService.swift | 6 +++++- Bitkit/Services/LightningService.swift | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 2c3f15ecb..a724e18af 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -289,7 +289,11 @@ class ActivityService { updatedCount += 1 // Remove metadata after successful update - try? TransactionMetadataStorage.shared.remove(txId: metadata.txId) + do { + try TransactionMetadataStorage.shared.remove(txId: metadata.txId) + } catch { + Logger.error("Failed to remove metadata for \(metadata.txId): \(error)", context: "CoreService.updateActivitiesMetadata") + } removedCount += 1 Logger.debug("Updated activity with metadata: \(metadata.txId)", context: "CoreService.updateActivitiesMetadata") diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 719d94f4d..e993e8faa 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -391,7 +391,11 @@ class LightningService { channelId: nil, createdAt: UInt64(Date().timeIntervalSince1970) ) - try? TransactionMetadataStorage.shared.insert(metadata) + do { + try TransactionMetadataStorage.shared.insert(metadata) + } catch { + Logger.error("Failed to insert transaction metadata", context: error) + } Logger.debug("Captured transaction metadata for txid: \(txid), isTransfer: \(isTransfer)", context: "LightningService") return txid From a69df2aef31a18d1b8a64089ed942b1c1f27ecf0 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 12 Nov 2025 09:55:12 -0300 Subject: [PATCH 15/20] chore: log --- Bitkit/ViewModels/TransferViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index 8f2ff0f30..6764e3723 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -660,7 +660,7 @@ class TransferViewModel: ObservableObject { if !errors.isEmpty { let errorMessages = errors.map { "\($0.channelId): \($0.error.localizedDescription)" }.joined(separator: ", ") throw AppError( - message: "Failed to force close \(errors.count) of \(errors.count + successfulChannels.count) channel(s)", + message: "Failed to force close \(errors.count) of \(channelsToClose.count)) channel(s)", debugMessage: errorMessages ) } From 878f35b918235f7e75e9b91aef9db378c094a757 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 12 Nov 2025 12:54:33 -0300 Subject: [PATCH 16/20] fix: update pbxproj --- Bitkit.xcodeproj/project.pbxproj | 2 ++ Bitkit/Services/LightningService.swift | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 884e39e96..b9e7d39d7 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -112,9 +112,11 @@ Models/LnPeer.swift, Models/ReceivedTxSheetDetails.swift, Models/Toast.swift, + Models/TransactionMetadata.swift, Services/CoreService.swift, Services/LightningService.swift, Services/ServiceQueue.swift, + Services/TransactionMetadataStorage.swift, Services/VssStoreIdProvider.swift, Utilities/AddressChecker.swift, Utilities/Crypto.swift, diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index e993e8faa..dffba9ddb 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -394,7 +394,7 @@ class LightningService { do { try TransactionMetadataStorage.shared.insert(metadata) } catch { - Logger.error("Failed to insert transaction metadata", context: error) + Logger.error("Failed to insert transaction metadata", context: error.localizedDescription) } Logger.debug("Captured transaction metadata for txid: \(txid), isTransfer: \(isTransfer)", context: "LightningService") From bfa4025a7e4af4072375763e2a4d1f3b9a72c4a1 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 12 Nov 2025 13:18:57 -0300 Subject: [PATCH 17/20] fix: prevent rewrite with old payment data --- Bitkit/Services/CoreService.swift | 65 ++++++++++++++++++++++++------- 1 file changed, 50 insertions(+), 15 deletions(-) diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index a724e18af..236c3d319 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -117,7 +117,7 @@ class ActivityService { confirmedTimestamp = timestamp } - // Get existing activity to preserve certain flags like isBoosted and boostTxIds + // Get existing activity to preserve certain flags like isBoosted, boostTxIds, isTransfer, etc. let existingActivity = try getActivityById(activityId: payment.id) let existingOnchain: OnchainActivity? = { if let existingActivity, case let .onchain(existing) = existingActivity { @@ -127,6 +127,11 @@ class ActivityService { }() let preservedIsBoosted = existingOnchain?.isBoosted ?? false let preservedBoostTxIds = existingOnchain?.boostTxIds ?? [] + let preservedIsTransfer = existingOnchain?.isTransfer ?? false + let preservedChannelId = existingOnchain?.channelId + let preservedTransferTxId = existingOnchain?.transferTxId + let preservedFeeRate = existingOnchain?.feeRate ?? 1 + let preservedAddress = existingOnchain?.address ?? "todo_find_address" // Check if this is a replacement transaction (RBF) that should be marked as boosted let isReplacementTransaction = ActivityService.replacementTransactions.keys.contains(txid) @@ -167,25 +172,35 @@ class ActivityService { txId: txid, value: value, fee: (payment.feePaidMsat ?? 0) / 1000, - feeRate: 1, // TODO: get from somewhere - address: "todo_find_address", + feeRate: preservedFeeRate, // Preserve metadata fee rate + address: preservedAddress, // Preserve metadata address confirmed: isConfirmed, timestamp: timestamp, isBoosted: shouldMarkAsBoosted, // Mark as boosted if it's a replacement transaction boostTxIds: boostTxIds, - isTransfer: false, // TODO: handle when paying for order + isTransfer: preservedIsTransfer, // Preserve metadata isTransfer flag doesExist: true, confirmTimestamp: confirmedTimestamp, - channelId: nil, // TODO: get from linked order - transferTxId: nil, // TODO: get from linked order + channelId: preservedChannelId, // Preserve metadata channelId + transferTxId: preservedTransferTxId, // Preserve metadata transferTxId createdAt: UInt64(payment.creationTime.timeIntervalSince1970), updatedAt: timestamp ) - if existingActivity != nil { - try updateActivity(activityId: payment.id, activity: .onchain(onchain)) - print(payment) - updatedCount += 1 + if let existingOnchain { + // Only update if the new data is actually newer + // This prevents overwriting metadata updates with older LDK sync data + let existingUpdatedAt = existingOnchain.updatedAt ?? 0 + if timestamp >= existingUpdatedAt { + try updateActivity(activityId: payment.id, activity: .onchain(onchain)) + print(payment) + updatedCount += 1 + } else { + Logger.debug( + "Skipping update for \(txid) - existing data is newer (existing: \(existingUpdatedAt), new: \(timestamp))", + context: "CoreService.syncLdkNodePayments" + ) + } } else { try upsertActivity(activity: .onchain(onchain)) print(payment) @@ -253,8 +268,28 @@ class ActivityService { for metadata in allMetadata { do { - // Find activity by txId - guard let activity = try getActivityById(activityId: metadata.txId) else { + // Find activity by txId field (not by ID) + // Note: Activity IDs are payment.id, but we need to match on the txId field for onchain activities + let allActivities = try getActivities( + filter: nil, + txType: nil, + tags: nil, + search: nil, + minDate: nil, + maxDate: nil, + limit: nil, + sortDirection: nil + ) + var matchingActivity: Activity? + + for activity in allActivities { + if case let .onchain(onchainActivity) = activity, onchainActivity.txId == metadata.txId { + matchingActivity = activity + break + } + } + + guard let activity = matchingActivity else { Logger.debug( "Activity not found for txId: \(metadata.txId), keeping metadata for next sync", context: "CoreService.updateActivitiesMetadata" @@ -262,7 +297,7 @@ class ActivityService { continue } - // Only update onchain activities + // Only update onchain activities (already verified above) guard case var .onchain(onchainActivity) = activity else { Logger.debug("Activity \(metadata.txId) is not onchain, skipping", context: "CoreService.updateActivitiesMetadata") // Remove metadata since it won't be applicable @@ -284,8 +319,8 @@ class ActivityService { onchainActivity.updatedAt = UInt64(Date().timeIntervalSince1970) - // Save updated activity - try updateActivity(activityId: metadata.txId, activity: .onchain(onchainActivity)) + // Save updated activity using the activity ID (not txId) + try updateActivity(activityId: onchainActivity.id, activity: .onchain(onchainActivity)) updatedCount += 1 // Remove metadata after successful update From e27b06983ecf69c32cf9f3ea5f3ab157e1d2b3a8 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 12 Nov 2025 13:38:58 -0300 Subject: [PATCH 18/20] feat: transfer fee estimation --- .../Wallets/Activity/ActivityRowOnchain.swift | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift b/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift index d30c881e1..961c75b7d 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift @@ -43,13 +43,19 @@ struct ActivityRowOnchain: View { if item.isTransfer { switch item.txType { case .sent: - return item.confirmed ? - t("wallet__activity_transfer_spending_done") : - t("wallet__activity_transfer_spending_pending", variables: ["duration": "TODO"]) + if item.confirmed { + return t("wallet__activity_transfer_spending_done") + } else { + let feeDescription = TransactionSpeed.getFeeDescription(feeRate: item.feeRate, feeEstimates: feeEstimates) + return t("wallet__activity_transfer_spending_pending", variables: ["duration": feeDescription]) + } case .received: - return item.confirmed ? - t("wallet__activity_transfer_savings_done") : - t("wallet__activity_transfer_savings_pending", variables: ["duration": "TODO"]) + if item.confirmed { + return t("wallet__activity_transfer_savings_done") + } else { + let feeDescription = TransactionSpeed.getFeeDescription(feeRate: item.feeRate, feeEstimates: feeEstimates) + return t("wallet__activity_transfer_savings_pending", variables: ["duration": feeDescription]) + } } } else { if item.confirmed { From 46f8feda63f5ccac184227404d457f92d29933d6 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 12 Nov 2025 14:35:35 -0300 Subject: [PATCH 19/20] feat: backup activities --- Bitkit/Models/BackupPayloads.swift | 1 + Bitkit/Services/BackupService.swift | 13 ++++++++++++- Bitkit/ViewModels/SettingsViewModel.swift | 10 ++++++++-- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/Bitkit/Models/BackupPayloads.swift b/Bitkit/Models/BackupPayloads.swift index 0c02c4588..17ede1d3f 100644 --- a/Bitkit/Models/BackupPayloads.swift +++ b/Bitkit/Models/BackupPayloads.swift @@ -33,6 +33,7 @@ struct AppCacheData: Codable { let highBalanceIgnoreTimestamp: TimeInterval let dismissedSuggestions: [String] let lastUsedTags: [String] + let transactionsMetadata: [TransactionMetadata] } struct BlocktankBackupV1: Codable { diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index fa1f902e9..36495c515 100644 --- a/Bitkit/Services/BackupService.swift +++ b/Bitkit/Services/BackupService.swift @@ -222,6 +222,9 @@ class BackupService { await SettingsViewModel.shared.restoreAppCacheData(payload.cache) Logger.debug("Restored caches and \(payload.tagMetadata.count) tags metadata records", context: "BackupService") + + // Apply transaction metadata to activities after restore + try await CoreService.shared.activity.updateActivitiesMetadata() } try await performRestore(category: .blocktank) { dataBytes in @@ -324,6 +327,14 @@ class BackupService { } .store(in: &cancellables) + TransactionMetadataStorage.shared.metadataChangedPublisher + .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) + .sink { [weak self] _ in + guard let self, !self.isRestoring else { return } + markBackupRequired(category: .metadata) + } + .store(in: &cancellables) + // BLOCKTANK CoreService.shared.blocktank.stateChangedPublisher .debounce(for: .milliseconds(500), scheduler: DispatchQueue.main) @@ -348,7 +359,7 @@ class BackupService { } .store(in: &cancellables) - Logger.debug("Started 7 data store listeners", context: "BackupService") + Logger.debug("Started 8 data store listeners", context: "BackupService") } private func startPeriodicBackupFailureCheck() { diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index 6a84ad2b3..513a60780 100644 --- a/Bitkit/ViewModels/SettingsViewModel.swift +++ b/Bitkit/ViewModels/SettingsViewModel.swift @@ -420,7 +420,9 @@ class SettingsViewModel: NSObject, ObservableObject { /// Gets the current app cache data for backup func getAppCacheData() -> AppCacheData { - AppCacheData( + let transactionsMetadata = (try? TransactionMetadataStorage.shared.getAll()) ?? [] + + return AppCacheData( hasSeenContactsIntro: defaults.bool(forKey: "hasSeenContactsIntro"), hasSeenProfileIntro: defaults.bool(forKey: "hasSeenProfileIntro"), hasSeenNotificationsIntro: defaults.bool(forKey: "hasSeenNotificationsIntro"), @@ -436,7 +438,8 @@ class SettingsViewModel: NSObject, ObservableObject { highBalanceIgnoreCount: defaults.integer(forKey: "highBalanceIgnoreCount"), highBalanceIgnoreTimestamp: defaults.double(forKey: "highBalanceIgnoreTimestamp"), dismissedSuggestions: defaults.stringArray(forKey: "dismissedSuggestions") ?? [], - lastUsedTags: defaults.stringArray(forKey: "lastUsedTags") ?? [] + lastUsedTags: defaults.stringArray(forKey: "lastUsedTags") ?? [], + transactionsMetadata: transactionsMetadata ) } @@ -458,5 +461,8 @@ class SettingsViewModel: NSObject, ObservableObject { defaults.set(cache.highBalanceIgnoreTimestamp, forKey: "highBalanceIgnoreTimestamp") defaults.set(cache.dismissedSuggestions, forKey: "dismissedSuggestions") defaults.set(cache.lastUsedTags, forKey: "lastUsedTags") + + // Restore transaction metadata + try? TransactionMetadataStorage.shared.insertList(cache.transactionsMetadata) } } From b1f27b11e6e3f64a273545b88f9570897a95c968 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 13 Nov 2025 07:07:28 -0300 Subject: [PATCH 20/20] chore: update pbxproj --- Bitkit.xcodeproj/project.pbxproj | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index b9e7d39d7..b9506ffa4 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -88,10 +88,12 @@ Models/BlocktankNotificationType.swift, Models/LnPeer.swift, Models/Toast.swift, + Models/TransactionMetadata.swift, Services/CoreService.swift, Services/LightningService.swift, Services/MigrationsService.swift, Services/ServiceQueue.swift, + Services/TransactionMetadataStorage.swift, Services/VssStoreIdProvider.swift, Utilities/AddressChecker.swift, Utilities/Crypto.swift,