diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 884e39e9..b9506ffa 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, @@ -112,9 +114,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/AppScene.swift b/Bitkit/AppScene.swift index aab1b38e..28c87351 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -50,7 +50,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.shared) diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 948c9e66..4dae2806 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/Models/BackupPayloads.swift b/Bitkit/Models/BackupPayloads.swift index 0c02c458..17ede1d3 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/Models/TransactionMetadata.swift b/Bitkit/Models/TransactionMetadata.swift new file mode 100644 index 00000000..bda49995 --- /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 + } +} diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift index fa1f902e..36495c51 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/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index ae7d0be9..236c3d31 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) @@ -233,6 +248,103 @@ class ActivityService { Logger.info("Synced LDK payments - Added: \(addedCount) - Updated: \(updatedCount)", context: "CoreService") self.activitiesChangedSubject.send() } + + try await updateActivitiesMetadata() + } + + 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 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" + ) + continue + } + + // 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 + 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 using the activity ID (not txId) + try updateActivity(activityId: onchainActivity.id, activity: .onchain(onchainActivity)) + updatedCount += 1 + + // Remove metadata after successful update + 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") + } 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? { diff --git a/Bitkit/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index 33f67f4c..dffba9dd 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,24 @@ 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) + ) + do { + try TransactionMetadataStorage.shared.insert(metadata) + } catch { + Logger.error("Failed to insert transaction metadata", context: error.localizedDescription) + } + Logger.debug("Captured transaction metadata for txid: \(txid), isTransfer: \(isTransfer)", context: "LightningService") + + return txid } catch { dumpLdkLogs() throw error @@ -419,16 +441,26 @@ 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) } return try await ServiceQueue.background(.ldk) { - try node.closeChannel( - userChannelId: channel.userChannelId, - counterpartyNodeId: channel.counterpartyNodeId - ) + Logger.debug("Initiating channel close (force=\(force)): '\(channel.channelId)'", context: "LightningService") + + if force { + try node.forceCloseChannel( + userChannelId: channel.userChannelId, + counterpartyNodeId: channel.counterpartyNodeId, + reason: forceCloseReason ?? "" + ) + } else { + try node.closeChannel( + userChannelId: channel.userChannelId, + counterpartyNodeId: channel.counterpartyNodeId + ) + } } } diff --git a/Bitkit/Services/TransactionMetadataStorage.swift b/Bitkit/Services/TransactionMetadataStorage.swift new file mode 100644 index 00000000..835a4d5e --- /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) + } +} diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift index 6a84ad2b..513a6078 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) } } diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift index c20533e9..2e2d37b8 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 99dd25d8..6764e372 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 let sheetViewModel: SheetViewModel private var refreshTimer: Timer? private var refreshTask: Task? @@ -39,19 +40,22 @@ class TransferViewModel: ObservableObject { coreService: CoreService = .shared, lightningService: LightningService = .shared, currencyService: CurrencyService = .shared, - transferService: TransferService + transferService: TransferService, + sheetViewModel: SheetViewModel ) { self.coreService = coreService self.lightningService = lightningService self.currencyService = currencyService self.transferService = transferService + self.sheetViewModel = sheetViewModel } /// Convenience initializer for testing and previews convenience init( coreService: CoreService = .shared, lightningService: LightningService = .shared, - currencyService: CurrencyService = .shared + currencyService: CurrencyService = .shared, + sheetViewModel: SheetViewModel = SheetViewModel() ) { let transferService = TransferService( lightningService: lightningService, @@ -61,7 +65,8 @@ class TransferViewModel: ObservableObject { coreService: coreService, lightningService: lightningService, currencyService: currencyService, - transferService: transferService + transferService: transferService, + sheetViewModel: sheetViewModel ) } @@ -125,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 { @@ -605,8 +615,54 @@ 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.warn("No channels to force close") + return + } + + 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( + channel, + force: true, + 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) + errors.append((channelId: channel.channelId, error: error)) + } + } + + // Remove successfully closed channels from the list + channelsToClose.removeAll { channel in + successfulChannels.contains { $0.channelId == channel.channelId } + } + + 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 \(channelsToClose.count)) channel(s)", + debugMessage: errorMessages + ) } } } diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 53368dfc..0ae30c41 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.") } @@ -250,7 +251,8 @@ class WalletViewModel: ObservableObject { sats: sats, satsPerVbyte: selectedFeeRateSatsPerVByte, utxosToSpend: selectedUtxos, - isMaxAmount: isMaxAmount + isMaxAmount: isMaxAmount, + isTransfer: isTransfer ) Task { diff --git a/Bitkit/Views/Sheets/ForceTransferSheet.swift b/Bitkit/Views/Sheets/ForceTransferSheet.swift new file mode 100644 index 00000000..ccd4dce1 --- /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 + } + } +} diff --git a/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift b/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift index d30c881e..961c75b7 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 {