From 00cbc8deccdc2c98869d3ca28c94990d81391c98 Mon Sep 17 00:00:00 2001 From: benk10 Date: Sun, 16 Nov 2025 20:29:53 -0500 Subject: [PATCH 1/2] Update transfer activity details --- .../ChannelDetails+Extensions.swift | 42 ++++++ .../Localization/en.lproj/Localizable.strings | 2 + Bitkit/Services/CoreService.swift | 56 ++++++- Bitkit/ViewModels/TransferViewModel.swift | 65 ++------ .../Advanced/LightningConnectionsView.swift | 44 +----- .../Wallets/Activity/ActivityItemView.swift | 139 +++++++++++++++--- 6 files changed, 231 insertions(+), 117 deletions(-) diff --git a/Bitkit/Extensions/ChannelDetails+Extensions.swift b/Bitkit/Extensions/ChannelDetails+Extensions.swift index 405eb3cd..8f6859c8 100644 --- a/Bitkit/Extensions/ChannelDetails+Extensions.swift +++ b/Bitkit/Extensions/ChannelDetails+Extensions.swift @@ -1,3 +1,4 @@ +import BitkitCore import Foundation import LDKNode @@ -6,6 +7,47 @@ extension ChannelDetails { var spendableBalanceSats: UInt64 { return outboundCapacityMsat / 1000 + (unspendablePunishmentReserve ?? 0) } + + /// Find the linked Blocktank order for this channel + /// - Parameter orders: Array of Blocktank orders to search + /// - Returns: The matching order if found, nil otherwise + func findLinkedOrder(in orders: [IBtOrder]) -> IBtOrder? { + // Match by userChannelId (which is set to order.id for Blocktank orders) + if let order = orders.first(where: { $0.id == userChannelId }) { + return order + } + + // Match by short channel ID + if let shortChannelId { + let shortChannelIdString = String(shortChannelId) + if let order = orders.first(where: { order in + order.channel?.shortChannelId == shortChannelIdString + }) { + return order + } + } + + // Match by funding transaction + if let fundingTxo { + if let order = orders.first(where: { order in + order.channel?.fundingTx.id == fundingTxo.txid + }) { + return order + } + } + + // Match by counterparty node ID (less reliable, could match multiple) + let counterpartyNodeIdString = counterpartyNodeId.description + if let order = orders.first(where: { order in + guard let orderChannel = order.channel else { return false } + return orderChannel.clientNodePubkey == counterpartyNodeIdString || + orderChannel.lspNodePubkey == counterpartyNodeIdString + }) { + return order + } + + return nil + } } // MARK: - Mock Data diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index e7eb10d7..b8f11f2a 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1007,6 +1007,7 @@ "wallet__activity_transfer_spending_pending" = "From Savings (±{duration})"; "wallet__activity_transfer_spending_done" = "From Savings"; "wallet__activity_transfer_to_spending" = "To Spending"; +"wallet__activity_transfer_to_savings" = "To Savings"; "wallet__activity_transfer_pending" = "Transfer (±{duration})"; "wallet__activity_confirms_in" = "Confirms in {feeRateDescription}"; "wallet__activity_confirms_in_boosted" = "Boosting. Confirms in {feeRateDescription}"; @@ -1023,6 +1024,7 @@ "wallet__activity_removed_msg" = "Please check your activity list. The {count} impacted transaction(s) will be highlighted in red."; "wallet__activity_boosting" = "Boosting"; "wallet__activity_fee" = "Fee"; +"wallet__activity_fee_prepaid" = "Fee (Prepaid)"; "wallet__activity_payment" = "Payment"; "wallet__activity_status" = "Status"; "wallet__activity_date" = "Date"; diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 3024f43a..be28b653 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -151,9 +151,17 @@ class ActivityService { let preservedFeeRate = existingOnchain?.feeRate ?? 1 let preservedAddress = existingOnchain?.address ?? "Loading..." - // Check if this transaction is a channel close by checking if it spends a closed channel's funding UTXO - if payment.direction == .inbound && (preservedChannelId == nil || !preservedIsTransfer) { - if let channelId = await self.findClosedChannelForTransaction(txid: txid) { + // Check if this transaction is a channel transfer (open or close) + if preservedChannelId == nil || !preservedIsTransfer { + let channelId: String? = if payment.direction == .inbound { + // Check if this transaction is a channel close by checking if it spends a closed channel's funding UTXO + await self.findClosedChannelForTransaction(txid: txid) + } else { + // Check if this transaction is a channel open by checking if it's the funding transaction for an open channel + await self.findOpenChannelForTransaction(txid: txid) + } + + if let channelId { preservedChannelId = channelId preservedIsTransfer = true } @@ -324,6 +332,48 @@ class ActivityService { return nil } + /// Check if a transaction is the funding transaction for an open channel + private func findOpenChannelForTransaction(txid: String) async -> String? { + guard let channels = LightningService.shared.channels, !channels.isEmpty else { + return nil + } + + // First, check if the transaction matches any channel's funding transaction directly + if let channel = channels.first(where: { $0.fundingTxo?.txid.description == txid }) { + return channel.channelId.description + } + + // If no direct match, check Blocktank orders for payment transactions + do { + let orders = try await coreService.blocktank.orders(orderIds: nil, filter: nil, refresh: false) + + // Find order with matching payment transaction + guard let order = orders.first(where: { order in + order.payment?.onchain?.transactions.contains { $0.txId == txid } ?? false + }) else { + return nil + } + + // Find channel that matches this order's channel funding transaction + guard let orderChannel = order.channel else { + return nil + } + + if let channel = channels.first(where: { channel in + channel.fundingTxo?.txid.description == orderChannel.fundingTx.id + }) { + return channel.channelId.description + } + } catch { + Logger.warn( + "Failed to fetch Blocktank orders: \(error)", + context: "CoreService.findOpenChannelForTransaction" + ) + } + + return nil + } + /// Check pre-activity metadata for addresses in the transaction private func findAddressInPreActivityMetadata(txDetails: TxDetails, value: UInt64) async -> String? { for output in txDetails.vout { diff --git a/Bitkit/ViewModels/TransferViewModel.swift b/Bitkit/ViewModels/TransferViewModel.swift index ca70cd47..61bfc76e 100644 --- a/Bitkit/ViewModels/TransferViewModel.swift +++ b/Bitkit/ViewModels/TransferViewModel.swift @@ -135,7 +135,20 @@ class TransferViewModel: ObservableObject { // Create transfer tracking record for spending do { // Create pre-activity metadata for the transfer transaction - await createTransferMetadata(txId: txid, feeRate: UInt64(satsPerVbyte), address: address) + let currentTime = UInt64(Date().timeIntervalSince1970) + let preActivityMetadata = BitkitCore.PreActivityMetadata( + paymentId: txid, + tags: [], + paymentHash: nil, + txId: txid, + address: address, + isReceive: false, + feeRate: UInt64(satsPerVbyte), + isTransfer: true, + channelId: nil, + createdAt: currentTime + ) + try? await coreService.activity.addPreActivityMetadata(preActivityMetadata) let transferId = try await transferService.createTransfer( type: .toSpending, @@ -411,11 +424,6 @@ class TransferViewModel: ObservableObject { // Create transfer tracking record with the ACTUAL channel ID (not user channel ID) do { - // Create pre-activity metadata for the transfer transaction if we have a fundingTxId - if let fundingTxId { - await createTransferMetadata(txId: fundingTxId, channelId: actualChannelId) - } - let transferId = try await transferService.createTransfer( type: .toSpending, amountSats: amountSats, @@ -692,51 +700,6 @@ class TransferViewModel: ObservableObject { ) } } - - /// Create pre-activity metadata for a transfer transaction, or update existing activity if it already exists - private func createTransferMetadata(txId: String, channelId: String? = nil, feeRate: UInt64? = nil, address: String? = nil) async { - // Check if activity already exists (may have been synced from LDK while waiting for channel pending event) - do { - let activities = try await coreService.activity.get(filter: .onchain, limit: 20) - if let existingActivity = activities.first(where: { activity in - guard case let .onchain(onchain) = activity else { return false } - return onchain.txId == txId - }), - case var .onchain(onchain) = existingActivity - { - // Activity already exists, update it directly with transfer metadata - onchain.isTransfer = true - onchain.channelId = channelId - if let feeRate, feeRate > 0 { - onchain.feeRate = feeRate - } - if let address, !address.isEmpty { - onchain.address = address - } - try? await coreService.activity.update(id: onchain.id, activity: .onchain(onchain)) - Logger.info("Updated existing activity \(onchain.id) with transfer metadata", context: "TransferViewModel") - return - } - } catch { - Logger.warn("Failed to check for existing activity: \(error)", context: "TransferViewModel") - } - - // Activity doesn't exist yet, create pre-activity metadata (will be applied on insert) - let currentTime = UInt64(Date().timeIntervalSince1970) - let preActivityMetadata = BitkitCore.PreActivityMetadata( - paymentId: txId, - tags: [], - paymentHash: nil, - txId: txId, - address: address, - isReceive: false, - feeRate: feeRate ?? 0, - isTransfer: true, - channelId: channelId, - createdAt: currentTime - ) - try? await coreService.activity.addPreActivityMetadata(preActivityMetadata) - } } /// Actor to safely capture channel data from channel pending events diff --git a/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift b/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift index 19403fe1..ae45bafe 100644 --- a/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift +++ b/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift @@ -356,49 +356,7 @@ struct LightningConnectionsView: View { private func findLinkedOrder(for channel: ChannelDetails) -> IBtOrder? { guard let orders = blocktank.orders else { return nil } - - // For fake channels created from orders, match by userChannelId (which we set to order.id) - for order in orders { - if order.id == channel.userChannelId { - return order - } - } - - // Try to match by short channel ID first (most reliable) - if let shortChannelId = channel.shortChannelId { - let shortChannelIdString = String(shortChannelId) - for order in orders { - if let orderChannel = order.channel, - let orderShortChannelId = orderChannel.shortChannelId, - orderShortChannelId == shortChannelIdString - { - return order - } - } - } - - // Try to match by funding transaction if available - if let fundingTxo = channel.fundingTxo { - for order in orders { - if let orderChannel = order.channel, - orderChannel.fundingTx.id == fundingTxo.txid - { - return order - } - } - } - - // Try to match by counterparty node ID (less reliable, could match multiple) - let counterpartyNodeIdString = channel.counterpartyNodeId.description - for order in orders { - if let orderChannel = order.channel, - orderChannel.clientNodePubkey == counterpartyNodeIdString || orderChannel.lspNodePubkey == counterpartyNodeIdString - { - return order - } - } - - return nil + return channel.findLinkedOrder(in: orders) } private func formatNumber(_ number: UInt64) -> String { diff --git a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift index 2b59a370..017a4449 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift @@ -9,6 +9,8 @@ struct ActivityItemView: View { @EnvironmentObject var currency: CurrencyViewModel @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var sheets: SheetViewModel + @EnvironmentObject var wallet: WalletViewModel + @EnvironmentObject var blocktank: BlocktankViewModel @StateObject private var viewModel: ActivityItemViewModel init(item: Activity) { @@ -69,13 +71,54 @@ struct ActivityItemView: View { } } + private var isTransferFromSpending: Bool { + isTransfer && !isSent + } + + private var isTransferToSpending: Bool { + isTransfer && isSent + } + private var accentColor: Color { - isLightning ? .purpleAccent : .brandAccent + if isTransferFromSpending { + return .purpleAccent + } + return isLightning ? .purpleAccent : .brandAccent + } + + private var transferChannelId: String? { + guard case let .onchain(activity) = viewModel.activity else { return nil } + return activity.channelId + } + + @State private var closedChannels: [ClosedChannelDetails] = [] + @State private var foundChannel: ChannelDisplayable? = nil + + private var transferChannel: ChannelDisplayable? { + // Return cached found channel if available + if let found = foundChannel { + return found + } + + // Quick synchronous check for open channels (before async task completes) + guard let channelId = transferChannelId, + let channels = wallet.channels, + let openChannel = channels.first(where: { $0.channelId.description == channelId }) + else { + return nil + } + return openChannel + } + + private var findLinkedOrder: IBtOrder? { + guard let channel = transferChannel as? ChannelDetails, + let orders = blocktank.orders else { return nil } + return channel.findLinkedOrder(in: orders) } private var navigationTitle: String { if isTransfer { - return isSent + return isTransferToSpending ? t("wallet__activity_transfer_spending_done") : t("wallet__activity_transfer_savings_done") } @@ -171,6 +214,28 @@ struct ActivityItemView: View { } } } + .task { + // Load closed channels if this is a transfer (to find the channel) + guard isTransfer, let channelId = transferChannelId else { return } + + // First check if channel is already in open channels + if let channels = wallet.channels, + let openChannel = channels.first(where: { $0.channelId.description == channelId }) + { + foundChannel = openChannel + return + } + + // Load closed channels + do { + closedChannels = try await CoreService.shared.activity.closedChannels() + if let closedChannel = closedChannels.first(where: { $0.channelId == channelId }) { + foundChannel = closedChannel + } + } catch { + Logger.warn("Failed to load closed channels: \(error)") + } + } } @ViewBuilder @@ -215,10 +280,12 @@ struct ActivityItemView: View { textColor: .yellowAccent ) } else { + // Use accent color for transfers (purple for from spending, orange for from savings) + let statusColor = isTransfer ? accentColor : .brandAccent Image("hourglass-simple") - .foregroundColor(.brandAccent) + .foregroundColor(statusColor) .frame(width: 16, height: 16) - BodySSBText(t("wallet__activity_confirming"), textColor: .brandAccent) + BodySSBText(t("wallet__activity_confirming"), textColor: statusColor) } } } @@ -268,16 +335,26 @@ struct ActivityItemView: View { @ViewBuilder private var feeSection: some View { - if isSent { + if isSent || isTransfer { HStack(spacing: 16) { VStack(alignment: .leading, spacing: 0) { - CaptionMText(t("wallet__activity_payment")) - .padding(.bottom, 8) + CaptionMText( + isTransferToSpending ? t("wallet__activity_transfer_to_spending") : + isTransferFromSpending ? t("wallet__activity_transfer_to_savings") : + t("wallet__activity_payment") + ) + .padding(.bottom, 8) HStack(spacing: 4) { - Image("user") - .foregroundColor(accentColor) - .frame(width: 16, height: 16) + Image( + isTransferToSpending ? "bolt-hollow" : + isTransferFromSpending ? "status-bitcoin" : + "user" + ) + .resizable() + .scaledToFit() + .foregroundColor(accentColor) + .frame(width: 16, height: 16) MoneyText(sats: Int(activity.value), size: .bodySSB, testIdentifier: "MoneyText") } .accessibilityElement(children: .contain) @@ -289,15 +366,22 @@ struct ActivityItemView: View { .frame(maxWidth: .infinity, alignment: .leading) if let fee = activity.fee { + let feeLabel = isTransferFromSpending ? t("wallet__activity_fee_prepaid") : t("wallet__activity_fee") + let feeAmount: UInt64 = if isTransferToSpending, let order = findLinkedOrder { + order.serviceFeeSat + order.networkFeeSat + } else { + fee + } + VStack(alignment: .leading, spacing: 0) { - CaptionMText(t("wallet__activity_fee")) + CaptionMText(feeLabel) .padding(.bottom, 8) HStack(spacing: 4) { Image("timer") .foregroundColor(accentColor) .frame(width: 16, height: 16) - MoneyText(sats: Int(fee), size: .bodySSB, testIdentifier: "MoneyText") + MoneyText(sats: Int(feeAmount), size: .bodySSB, testIdentifier: "MoneyText") } .accessibilityElement(children: .contain) .accessibilityIdentifier("ActivityFee") @@ -402,15 +486,30 @@ struct ActivityItemView: View { } .accessibilityIdentifier(boostButtonIdentifier) - CustomButton( - title: t("wallet__activity_explore"), size: .small, - icon: Image("branch") - .foregroundColor(accentColor), - shouldExpand: true - ) { - navigation.navigate(.activityExplorer(viewModel.activity)) + if isTransfer, let channel = transferChannel { + CustomButton( + title: t("lightning__connection"), size: .small, + icon: Image("bolt-hollow") + .foregroundColor(accentColor), + shouldExpand: true, + destination: LightningConnectionDetailView( + channel: channel, + linkedOrder: findLinkedOrder, + title: t("lightning__connection") + ) + ) + .accessibilityIdentifier("ChannelButton") + } else { + CustomButton( + title: t("wallet__activity_explore"), size: .small, + icon: Image("branch") + .foregroundColor(accentColor), + shouldExpand: true + ) { + navigation.navigate(.activityExplorer(viewModel.activity)) + } + .accessibilityIdentifier("ActivityTxDetails") } - .accessibilityIdentifier("ActivityTxDetails") } .frame(maxWidth: .infinity) } From 36f64e2efdf1ad0398ee6d3ff5d282fc012b6027 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 17 Nov 2025 11:24:02 -0500 Subject: [PATCH 2/2] Add channel details to navigation and fix comments --- Bitkit/AppScene.swift | 2 + .../ChannelDetails+Extensions.swift | 41 --- Bitkit/MainNavView.swift | 1 + .../Localization/en.lproj/Localizable.strings | 2 + Bitkit/Services/CoreService.swift | 20 +- Bitkit/ViewModels/ActivityItemViewModel.swift | 15 ++ .../ViewModels/ChannelDetailsViewModel.swift | 188 +++++++++++++ Bitkit/ViewModels/NavigationViewModel.swift | 1 + .../Advanced/ChannelDisplayable.swift | 2 +- .../LightningConnectionDetailView.swift | 252 ++++++++++-------- .../Advanced/LightningConnectionsView.swift | 137 +++------- .../Wallets/Activity/ActivityItemView.swift | 67 +---- 12 files changed, 407 insertions(+), 321 deletions(-) create mode 100644 Bitkit/ViewModels/ChannelDetailsViewModel.swift diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 28c87351..8b7583c0 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -22,6 +22,7 @@ struct AppScene: View { @StateObject private var suggestionsManager = SuggestionsManager() @StateObject private var tagManager = TagManager() @StateObject private var transferTracking: TransferTrackingManager + @StateObject private var channelDetails = ChannelDetailsViewModel.shared @State private var hideSplash = false @State private var removeSplash = false @@ -88,6 +89,7 @@ struct AppScene: View { .environmentObject(suggestionsManager) .environmentObject(tagManager) .environmentObject(transferTracking) + .environmentObject(channelDetails) .onAppear { if !settings.pinEnabled { isPinVerified = true diff --git a/Bitkit/Extensions/ChannelDetails+Extensions.swift b/Bitkit/Extensions/ChannelDetails+Extensions.swift index 8f6859c8..14e026b3 100644 --- a/Bitkit/Extensions/ChannelDetails+Extensions.swift +++ b/Bitkit/Extensions/ChannelDetails+Extensions.swift @@ -7,47 +7,6 @@ extension ChannelDetails { var spendableBalanceSats: UInt64 { return outboundCapacityMsat / 1000 + (unspendablePunishmentReserve ?? 0) } - - /// Find the linked Blocktank order for this channel - /// - Parameter orders: Array of Blocktank orders to search - /// - Returns: The matching order if found, nil otherwise - func findLinkedOrder(in orders: [IBtOrder]) -> IBtOrder? { - // Match by userChannelId (which is set to order.id for Blocktank orders) - if let order = orders.first(where: { $0.id == userChannelId }) { - return order - } - - // Match by short channel ID - if let shortChannelId { - let shortChannelIdString = String(shortChannelId) - if let order = orders.first(where: { order in - order.channel?.shortChannelId == shortChannelIdString - }) { - return order - } - } - - // Match by funding transaction - if let fundingTxo { - if let order = orders.first(where: { order in - order.channel?.fundingTx.id == fundingTxo.txid - }) { - return order - } - } - - // Match by counterparty node ID (less reliable, could match multiple) - let counterpartyNodeIdString = counterpartyNodeId.description - if let order = orders.first(where: { order in - guard let orderChannel = order.channel else { return false } - return orderChannel.clientNodePubkey == counterpartyNodeIdString || - orderChannel.lspNodePubkey == counterpartyNodeIdString - }) { - return order - } - - return nil - } } // MARK: - Mock Data diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index 4dae2806..377791cb 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -360,6 +360,7 @@ struct MainNavView: View { // Advanced settings case .coinSelection: CoinSelectionSettingsView() case .connections: LightningConnectionsView() + case let .connectionDetail(channelId): LightningConnectionDetailView(channelId: channelId) case let .closeConnection(channel: channel): CloseConnectionConfirmation(channel: channel) case .node: NodeStateView() case .electrumSettings: ElectrumSettingsScreen() diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index b8f11f2a..86c90483 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -213,6 +213,8 @@ "lightning__peer_saved" = "The Lightning peer was successfully added and saved."; "lightning__invoice_copied" = "Copied Invoice to Clipboard"; "lightning__connection" = "Connection"; +"lightning__connection_not_found_title" = "Connection Not Found"; +"lightning__connection_not_found_message" = "Failed to load connection details. Please try again later."; "lightning__status" = "Status"; "lightning__order_details" = "Order Details"; "lightning__order" = "Order ID"; diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index be28b653..4a165f2e 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -153,13 +153,7 @@ class ActivityService { // Check if this transaction is a channel transfer (open or close) if preservedChannelId == nil || !preservedIsTransfer { - let channelId: String? = if payment.direction == .inbound { - // Check if this transaction is a channel close by checking if it spends a closed channel's funding UTXO - await self.findClosedChannelForTransaction(txid: txid) - } else { - // Check if this transaction is a channel open by checking if it's the funding transaction for an open channel - await self.findOpenChannelForTransaction(txid: txid) - } + let channelId = await self.findChannelForTransaction(txid: txid, direction: payment.direction) if let channelId { preservedChannelId = channelId @@ -304,6 +298,18 @@ class ActivityService { } } + /// Finds the channel ID associated with a transaction based on its direction + private func findChannelForTransaction(txid: String, direction: PaymentDirection) async -> String? { + switch direction { + case .inbound: + // Check if this transaction is a channel close by checking if it spends a closed channel's funding UTXO + return await findClosedChannelForTransaction(txid: txid) + case .outbound: + // Check if this transaction is a channel open by checking if it's the funding transaction for an open channel + return await findOpenChannelForTransaction(txid: txid) + } + } + /// Check if a transaction spends a closed channel's funding UTXO private func findClosedChannelForTransaction(txid: String) async -> String? { do { diff --git a/Bitkit/ViewModels/ActivityItemViewModel.swift b/Bitkit/ViewModels/ActivityItemViewModel.swift index ad6334e5..e76da3a9 100644 --- a/Bitkit/ViewModels/ActivityItemViewModel.swift +++ b/Bitkit/ViewModels/ActivityItemViewModel.swift @@ -99,4 +99,19 @@ class ActivityItemViewModel: ObservableObject { Logger.error(error, context: "Failed to refresh activity \(activityId)") } } + + func calculateFeeAmount(linkedOrder: IBtOrder?) -> UInt64? { + switch activity { + case let .lightning(lightningActivity): + return lightningActivity.fee + case let .onchain(onchainActivity): + let isTransferToSpending = onchainActivity.isTransfer && onchainActivity.txType == .sent + + if isTransferToSpending, let order = linkedOrder { + return order.serviceFeeSat + order.networkFeeSat + } + + return onchainActivity.fee + } + } } diff --git a/Bitkit/ViewModels/ChannelDetailsViewModel.swift b/Bitkit/ViewModels/ChannelDetailsViewModel.swift new file mode 100644 index 00000000..f72f120f --- /dev/null +++ b/Bitkit/ViewModels/ChannelDetailsViewModel.swift @@ -0,0 +1,188 @@ +import BitkitCore +import Foundation +import LDKNode +import SwiftUI + +/// View model for loading channel details and finding their linked Blocktank orders +@MainActor +class ChannelDetailsViewModel: ObservableObject { + static let shared = ChannelDetailsViewModel() + + @Published var foundChannel: ChannelDisplayable? = nil + @Published var linkedOrder: IBtOrder? = nil + @Published var isLoading = false + @Published var error: Error? = nil + + private let coreService: CoreService + + /// Private initializer for the singleton instance + private init(coreService: CoreService = .shared) { + self.coreService = coreService + } + + /// Find a channel by ID, checking open channels, pending channels, pending orders, then closed channels + func findChannel(channelId: String, wallet: WalletViewModel) async { + // Clear any previously found channel and order to avoid returning stale data + foundChannel = nil + linkedOrder = nil + isLoading = true + error = nil + + guard !channelId.isEmpty else { + isLoading = false + return + } + + // First check if channel is already in open channels + if let channels = wallet.channels, + let openChannel = channels.first(where: { $0.channelId.description == channelId }) + { + foundChannel = openChannel + linkedOrder = await findLinkedOrder(for: openChannel) + isLoading = false + return + } + + // Check pending connections (pending channels + fake channels from pending orders) + let pending = await pendingConnections(wallet: wallet) + if let pendingChannel = pending.first(where: { $0.channelId.description == channelId }) { + foundChannel = pendingChannel + linkedOrder = await findLinkedOrder(for: pendingChannel) + isLoading = false + return + } + + // Load closed channels if not found in open, pending channels, or pending orders + do { + let closedChannels = try await coreService.activity.closedChannels() + if let closedChannel = closedChannels.first(where: { $0.channelId == channelId }) { + foundChannel = closedChannel + linkedOrder = await findLinkedOrder(for: closedChannel) + } else { + foundChannel = nil + linkedOrder = nil + } + } catch { + Logger.warn("Failed to load closed channels: \(error)") + self.error = error + foundChannel = nil + linkedOrder = nil + } + + isLoading = false + } + + /// Find the linked Blocktank order for a channel (works for both open and closed channels) + func findLinkedOrder(for channel: ChannelDisplayable) async -> IBtOrder? { + guard let orders = try? await coreService.blocktank.orders(refresh: false) else { return nil } + + // For open channels, try matching by userChannelId first (which is set to order.id for Blocktank orders) + if let openChannel = channel as? ChannelDetails { + if let order = orders.first(where: { $0.id == openChannel.userChannelId }) { + return order + } + + // Match by short channel ID (only available for open channels) + if let shortChannelId = openChannel.shortChannelId { + let shortChannelIdString = String(shortChannelId) + if let order = orders.first(where: { order in + order.channel?.shortChannelId == shortChannelIdString + }) { + return order + } + } + } + + // Match by funding transaction (works for both open and closed channels) + if let fundingTxId = channel.displayedFundingTxoTxid { + if let order = orders.first(where: { order in + order.channel?.fundingTx.id == fundingTxId + }) { + return order + } + } + + // Match by counterparty node ID (less reliable, could match multiple) + let counterpartyNodeIdString = channel.counterpartyNodeIdString + if let order = orders.first(where: { order in + guard let orderChannel = order.channel else { return false } + return orderChannel.clientNodePubkey == counterpartyNodeIdString || + orderChannel.lspNodePubkey == counterpartyNodeIdString + }) { + return order + } + + return nil + } + + /// Get pending connections (pending channels + fake channels from pending orders) + func pendingConnections(wallet: WalletViewModel) async -> [ChannelDetails] { + var connections: [ChannelDetails] = [] + + // Add actual pending channels + if let channels = wallet.channels { + connections.append(contentsOf: channels.filter { !$0.isChannelReady }) + } + + // Create fake channels from pending orders + guard let orders = try? await coreService.blocktank.orders(refresh: false) else { + return connections + } + + let pendingOrders = orders.filter { order in + // Include orders that are created or paid but not yet opened + order.state2 == .created || order.state2 == .paid + } + + for order in pendingOrders { + let fakeChannel = createFakeChannel(from: order) + connections.append(fakeChannel) + } + + return connections + } + + /// Creates a fake channel from a Blocktank order for UI display purposes + private func createFakeChannel(from order: IBtOrder) -> ChannelDetails { + return ChannelDetails( + channelId: order.id, + counterpartyNodeId: order.lspNode?.pubkey ?? "", + fundingTxo: OutPoint(txid: Txid(order.channel?.fundingTx.id ?? ""), vout: UInt32(order.channel?.fundingTx.vout ?? 0)), + shortChannelId: order.channel?.shortChannelId.flatMap(UInt64.init), + outboundScidAlias: nil, + inboundScidAlias: nil, + channelValueSats: order.lspBalanceSat + order.clientBalanceSat, + unspendablePunishmentReserve: 1000, + userChannelId: order.id, + feerateSatPer1000Weight: 2500, + outboundCapacityMsat: order.clientBalanceSat * 1000, + inboundCapacityMsat: order.lspBalanceSat * 1000, + confirmationsRequired: nil, + confirmations: 0, + isOutbound: false, + isChannelReady: false, + isUsable: false, + isAnnounced: false, + cltvExpiryDelta: 144, + counterpartyUnspendablePunishmentReserve: 1000, + counterpartyOutboundHtlcMinimumMsat: 1000, + counterpartyOutboundHtlcMaximumMsat: 99_000_000, + counterpartyForwardingInfoFeeBaseMsat: 1000, + counterpartyForwardingInfoFeeProportionalMillionths: 100, + counterpartyForwardingInfoCltvExpiryDelta: 144, + nextOutboundHtlcLimitMsat: order.clientBalanceSat * 1000, + nextOutboundHtlcMinimumMsat: 1000, + forceCloseSpendDelay: nil, + inboundHtlcMinimumMsat: 1000, + inboundHtlcMaximumMsat: order.lspBalanceSat * 1000, + config: .init( + forwardingFeeProportionalMillionths: 0, + forwardingFeeBaseMsat: 0, + cltvExpiryDelta: 0, + maxDustHtlcExposure: .feeRateMultiplier(multiplier: 0), + forceCloseAvoidanceMaxFeeSatoshis: 0, + acceptUnderpayingHtlcs: true + ) + ) + } +} diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index a0a39033..ff70e4fc 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -77,6 +77,7 @@ enum Route: Hashable { // Advanced settings case coinSelection case connections + case connectionDetail(channelId: String) case closeConnection(channel: ChannelDetails) case node case electrumSettings diff --git a/Bitkit/Views/Settings/Advanced/ChannelDisplayable.swift b/Bitkit/Views/Settings/Advanced/ChannelDisplayable.swift index bbe93d08..3d341320 100644 --- a/Bitkit/Views/Settings/Advanced/ChannelDisplayable.swift +++ b/Bitkit/Views/Settings/Advanced/ChannelDisplayable.swift @@ -23,7 +23,7 @@ protocol ChannelDisplayable { extension ChannelDetails: ChannelDisplayable { var channelIdString: String { - userChannelId.description + channelId.description } var counterpartyNodeIdString: String { diff --git a/Bitkit/Views/Settings/Advanced/LightningConnectionDetailView.swift b/Bitkit/Views/Settings/Advanced/LightningConnectionDetailView.swift index 08a27ca8..e4750795 100644 --- a/Bitkit/Views/Settings/Advanced/LightningConnectionDetailView.swift +++ b/Bitkit/Views/Settings/Advanced/LightningConnectionDetailView.swift @@ -3,13 +3,12 @@ import LDKNode import SwiftUI struct LightningConnectionDetailView: View { - @EnvironmentObject var blocktank: BlocktankViewModel + @EnvironmentObject var app: AppViewModel @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var wallet: WalletViewModel + @EnvironmentObject var channelDetails: ChannelDetailsViewModel - let channel: ChannelDisplayable - let linkedOrder: IBtOrder? - let title: String + let channelId: String private static let dateFormatter: DateFormatter = { let formatter = DateFormatter() @@ -39,151 +38,172 @@ struct LightningConnectionDetailView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - NavigationBar(title: title) + NavigationBar(title: t("lightning__connection")) .padding(.bottom, 16) - - GeometryReader { geometry in - ScrollView(showsIndicators: false) { - VStack(alignment: .leading, spacing: 0) { - LightningChannel( - capacity: channel.channelValueSats, - localBalance: channel.outboundCapacityMsat / 1000, - remoteBalance: channel.inboundCapacityMsat / 1000, - status: channel.isClosed ? .closed : channelStatus + .task { + await channelDetails.findChannel(channelId: channelId, wallet: wallet) + + // If channel not found after loading, show toast and go back + if !channelDetails.isLoading, channelDetails.foundChannel == nil { + app.toast( + type: .error, + title: t("lightning__connection_not_found_title"), + description: t("lightning__connection_not_found_message") ) - .padding(.bottom, 28) - - VStack(alignment: .leading, spacing: 32) { - // STATUS Section - VStack(alignment: .leading, spacing: 16) { - Divider() - - CaptionMText(t("lightning__status")) + navigation.navigateBack() + } + } - HStack(alignment: .center, spacing: 8) { - CircularIcon( - icon: detailedStatus.icon, - iconColor: detailedStatus.color, - backgroundColor: detailedStatus.color.opacity(0.16), - size: 32 - ) + GeometryReader { _ in + ScrollView(showsIndicators: false) { + if let channel = channelDetails.foundChannel { + VStack(alignment: .leading, spacing: 0) { + LightningChannel( + capacity: channel.channelValueSats, + localBalance: channel.outboundCapacityMsat / 1000, + remoteBalance: channel.inboundCapacityMsat / 1000, + status: channel.isClosed ? .closed : channelStatus(for: channel) + ) + .padding(.bottom, 28) + + VStack(alignment: .leading, spacing: 32) { + // STATUS Section + VStack(alignment: .leading, spacing: 16) { + Divider() + + CaptionMText(t("lightning__status")) + + HStack(alignment: .center, spacing: 8) { + let status = detailedStatus(for: channel) + CircularIcon( + icon: status.icon, + iconColor: status.color, + backgroundColor: status.color.opacity(0.16), + size: 32 + ) + + BodyMSBText(status.text, textColor: status.color) + } - BodyMSBText(detailedStatus.text, textColor: detailedStatus.color) + Divider() } - Divider() - } + // ORDER DETAILS Section + if let order = channelDetails.linkedOrder { + VStack(alignment: .leading, spacing: 0) { + CaptionMText(t("lightning__order_details")) + .padding(.bottom, 16) - // ORDER DETAILS Section - if let order = linkedOrder { - VStack(alignment: .leading, spacing: 0) { - CaptionMText(t("lightning__order_details")) - .padding(.bottom, 16) + DetailRow(label: t("lightning__order"), value: order.id) - DetailRow(label: t("lightning__order"), value: order.id) + if let formattedDate = formatDate(order.createdAt) { + DetailRow(label: t("lightning__created_on"), value: formattedDate) + } - if let formattedDate = formatDate(order.createdAt) { - DetailRow(label: t("lightning__created_on"), value: formattedDate) - } + if channelStatus(for: channel) == .pending { + if let formattedExpiry = formatDate(order.orderExpiresAt) { + DetailRow(label: t("lightning__order_expiry"), value: formattedExpiry) + } + } - if channelStatus == .pending { - if let formattedExpiry = formatDate(order.orderExpiresAt) { - DetailRow(label: t("lightning__order_expiry"), value: formattedExpiry) + if channelStatus(for: channel) != .pending, let txid = channel.displayedFundingTxoTxid { + DetailRow(label: t("lightning__transaction"), value: txid) } - } - if channelStatus != .pending, let txid = channel.displayedFundingTxoTxid { - DetailRow(label: t("lightning__transaction"), value: txid) + DetailRowWithAmount(label: t("lightning__order_fee"), amount: order.feeSat - order.clientBalanceSat) } - - DetailRowWithAmount(label: t("lightning__order_fee"), amount: order.feeSat - order.clientBalanceSat) } - } - // BALANCE Section - VStack(alignment: .leading, spacing: 0) { - CaptionMText(t("lightning__balance")) - .padding(.bottom, 16) - - DetailRowWithAmount( - label: t("lightning__receiving_label"), - amount: channel.inboundCapacityMsat / 1000 - ) - DetailRowWithAmount( - label: t("lightning__spending_label"), - amount: channel.outboundCapacityMsat / 1000 - ) - DetailRowWithAmount( - label: t("lightning__reserve_balance"), - amount: channel.displayedUnspendablePunishmentReserve - ) - DetailRowWithAmount(label: t("lightning__total_size"), amount: channel.channelValueSats) - } + // BALANCE Section + VStack(alignment: .leading, spacing: 0) { + CaptionMText(t("lightning__balance")) + .padding(.bottom, 16) - // FEES Section - VStack(alignment: .leading, spacing: 0) { - CaptionMText(t("lightning__fees")) - .padding(.bottom, 16) + DetailRowWithAmount( + label: t("lightning__receiving_label"), + amount: channel.inboundCapacityMsat / 1000 + ) + DetailRowWithAmount( + label: t("lightning__spending_label"), + amount: channel.outboundCapacityMsat / 1000 + ) + DetailRowWithAmount( + label: t("lightning__reserve_balance"), + amount: channel.displayedUnspendablePunishmentReserve + ) + DetailRowWithAmount(label: t("lightning__total_size"), amount: channel.channelValueSats) + } - DetailRowWithAmount(label: t("lightning__base_fee"), amount: UInt64(channel.forwardingFeeBaseMsat / 1000)) - DetailRow(label: t("lightning__fee_rate"), value: "\(channel.forwardingFeeProportionalMillionths) ppm") - } + // FEES Section + VStack(alignment: .leading, spacing: 0) { + CaptionMText(t("lightning__fees")) + .padding(.bottom, 16) - // OTHER Section - VStack(alignment: .leading, spacing: 16) { - CaptionMText(t("lightning__other")) + DetailRowWithAmount(label: t("lightning__base_fee"), amount: UInt64(channel.forwardingFeeBaseMsat / 1000)) + DetailRow(label: t("lightning__fee_rate"), value: "\(channel.forwardingFeeProportionalMillionths) ppm") + } - VStack(spacing: 0) { - DetailRow(label: t("lightning__is_usable"), value: channel.isUsable ? t("common__yes") : t("common__no")) + // OTHER Section + VStack(alignment: .leading, spacing: 16) { + CaptionMText(t("lightning__other")) - // TODO: Add channel opening date - // if let formattedDate = formatDate(channel.fundingTxo) { - // DetailRow(label: t("lightning__opened_on"), value: formattedDate) - // } + VStack(spacing: 0) { + DetailRow(label: t("lightning__is_usable"), value: channel.isUsable ? t("common__yes") : t("common__no")) - if let closedAt = channel.displayedClosedAt { - if let formattedCloseDate = formatDate(closedAt) { - DetailRow(label: t("lightning__closed_on"), value: formattedCloseDate) + // TODO: Add channel opening date + // if let formattedDate = formatDate(channel.fundingTxo) { + // DetailRow(label: t("lightning__opened_on"), value: formattedDate) + // } + + if let closedAt = channel.displayedClosedAt { + if let formattedCloseDate = formatDate(closedAt) { + DetailRow(label: t("lightning__closed_on"), value: formattedCloseDate) + } } - } - DetailRow(label: t("lightning__channel_id"), value: channel.channelIdString) + DetailRow(label: t("lightning__channel_id"), value: channel.channelIdString) - if let txid = channel.displayedFundingTxoTxid, let vout = channel.fundingTxoVout { - DetailRow(label: t("lightning__channel_point"), value: "\(txid):\(vout)") - } + if let txid = channel.displayedFundingTxoTxid, let vout = channel.fundingTxoVout { + DetailRow(label: t("lightning__channel_point"), value: "\(txid):\(vout)") + } - DetailRow( - label: t("lightning__channel_node_id"), - value: channel.counterpartyNodeIdString - ) + DetailRow( + label: t("lightning__channel_node_id"), + value: channel.counterpartyNodeIdString + ) - if let reason = channel.closureReason { - DetailRow(label: t("lightning__closure_reason"), value: reason) + if let reason = channel.closureReason { + DetailRow(label: t("lightning__closure_reason"), value: reason) + } } } } - } + .padding(.bottom, 32) - Spacer(minLength: 32) - - // Bottom buttons - HStack(spacing: 16) { - CustomButton(title: t("lightning__support"), variant: .secondary) { - // TODO: Handle support action - navigation.navigate(Route.support) - } + // Bottom buttons + HStack(spacing: 16) { + CustomButton(title: t("lightning__support"), variant: .secondary) { + // TODO: Handle support action + navigation.navigate(Route.support) + } - if channelStatus == .open, let openChannel = channel as? ChannelDetails { - CustomButton(title: t("lightning__close_conn")) { - navigation.navigate(Route.closeConnection(channel: openChannel)) + if channelStatus(for: channel) == .open, let openChannel = channel as? ChannelDetails { + CustomButton(title: t("lightning__close_conn")) { + navigation.navigate(Route.closeConnection(channel: openChannel)) + } } } } + } else if channelDetails.isLoading { + // Loading state + VStack { + Spacer() + ActivityIndicator() + Spacer() + } } - .frame(minHeight: geometry.size.height) - .bottomSafeAreaPadding() } + .bottomSafeAreaPadding() } } .navigationBarHidden(true) @@ -192,7 +212,7 @@ struct LightningConnectionDetailView: View { // MARK: - Computed Properties - private var channelStatus: ChannelStatus { + private func channelStatus(for channel: ChannelDisplayable) -> ChannelStatus { if channel.isClosed { return .closed } @@ -202,7 +222,7 @@ struct LightningConnectionDetailView: View { return .open } - private var detailedStatus: (text: String, color: Color, icon: String) { + private func detailedStatus(for channel: ChannelDisplayable) -> (text: String, color: Color, icon: String) { // Handle closed channels if channel.isClosed { return ( @@ -228,7 +248,7 @@ struct LightningConnectionDetailView: View { ) } - if let order = linkedOrder { + if let order = channelDetails.linkedOrder { // If the channel is with the LSP, we can show a more accurate status for pending channels let orderState = order.state2 let paymentState = order.payment?.state2 diff --git a/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift b/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift index ae45bafe..2b1b9e89 100644 --- a/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift +++ b/Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift @@ -5,12 +5,14 @@ import SwiftUI struct LightningConnectionsView: View { @EnvironmentObject var app: AppViewModel @EnvironmentObject var blocktank: BlocktankViewModel + @EnvironmentObject var channelDetails: ChannelDetailsViewModel @EnvironmentObject var navigation: NavigationViewModel @EnvironmentObject var wallet: WalletViewModel @State private var showClosedConnections = false @State private var isRefreshing = false @State private var closedChannels: [ClosedChannelDetails] = [] + @State private var pendingConnections: [ChannelDetails] = [] var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -67,11 +69,9 @@ struct LightningConnectionsView: View { .padding(.top, 16) ForEach(Array(pendingConnections.enumerated()), id: \.element.channelId) { index, channel in - NavigationLink(destination: LightningConnectionDetailView( - channel: channel, - linkedOrder: findLinkedOrder(for: channel), - title: "\(t("lightning__connection")) \(index + 1)" - )) { + Button { + navigation.navigate(.connectionDetail(channelId: channel.channelIdString)) + } label: { VStack(spacing: 0) { HStack { SubtitleText("\(t("lightning__connection")) \(index + 1)") @@ -94,6 +94,7 @@ struct LightningConnectionsView: View { Divider() } } + .buttonStyle(PlainButtonStyle()) } } .padding(.bottom, 16) @@ -106,13 +107,9 @@ struct LightningConnectionsView: View { .padding(.top, 16) ForEach(Array(openChannels.enumerated()), id: \.element.channelId) { index, channel in - NavigationLink( - destination: LightningConnectionDetailView( - channel: channel, - linkedOrder: findLinkedOrder(for: channel), - title: "\(t("lightning__connection")) \(index + 1)" - ) - ) { + Button { + navigation.navigate(.connectionDetail(channelId: channel.channelIdString)) + } label: { VStack(spacing: 0) { HStack { SubtitleText("\(t("lightning__connection")) \(index + 1)") @@ -147,11 +144,9 @@ struct LightningConnectionsView: View { .padding(.top, 16) ForEach(Array(closedChannels.enumerated()), id: \.element.channelId) { index, channel in - NavigationLink(destination: LightningConnectionDetailView( - channel: channel, - linkedOrder: nil, - title: "\(t("lightning__connection")) \(index + 1)" - )) { + Button { + navigation.navigate(.connectionDetail(channelId: channel.channelIdString)) + } label: { VStack(spacing: 0) { HStack { SubtitleText("\(t("lightning__connection")) \(index + 1)") @@ -217,11 +212,7 @@ struct LightningConnectionsView: View { await refreshData() } .task { - do { - closedChannels = try await CoreService.shared.activity.closedChannels() - } catch { - Logger.error("Failed to load closed channels: \(error)") - } + await loadData() } } @@ -236,85 +227,37 @@ struct LightningConnectionsView: View { wallet.totalInboundLightningSats ?? 0 } - private var pendingChannels: [ChannelDetails] { + private var openChannels: [ChannelDetails] { guard let channels = wallet.channels else { return [] } - return channels.filter { !$0.isChannelReady } - } - - private var pendingOrders: [IBtOrder] { - guard let orders = blocktank.orders else { return [] } - return orders.filter { order in - // Include orders that are created or paid but not yet opened - order.state2 == .created || order.state2 == .paid - } + return channels.filter(\.isChannelReady) } - private var pendingConnections: [ChannelDetails] { - var connections: [ChannelDetails] = [] + // MARK: - Helper Methods - // Add actual pending channels - connections.append(contentsOf: pendingChannels) + private func loadData() async { + await withTaskGroup(of: Void.self) { group in + // Load pending connections + group.addTask { + let connections = await channelDetails.pendingConnections(wallet: wallet) + await MainActor.run { + pendingConnections = connections + } + } - // Create fake channels from pending orders - for order in pendingOrders { - let fakeChannel = createFakeChannel(from: order) - connections.append(fakeChannel) + // Load closed channels + group.addTask { + do { + let channels = try await CoreService.shared.activity.closedChannels() + await MainActor.run { + closedChannels = channels + } + } catch { + Logger.error("Failed to load closed channels: \(error)") + } + } } - - return connections } - /// Creates a fake channel from a Blocktank order for UI display purposes - private func createFakeChannel(from order: IBtOrder) -> ChannelDetails { - return ChannelDetails( - channelId: order.id, - counterpartyNodeId: order.lspNode?.pubkey ?? "", - fundingTxo: OutPoint(txid: Txid(order.channel?.fundingTx.id ?? ""), vout: UInt32(order.channel?.fundingTx.vout ?? 0)), - shortChannelId: order.channel?.shortChannelId.flatMap(UInt64.init), - outboundScidAlias: nil, - inboundScidAlias: nil, - channelValueSats: order.lspBalanceSat + order.clientBalanceSat, - unspendablePunishmentReserve: 1000, - userChannelId: order.id, - feerateSatPer1000Weight: 2500, - outboundCapacityMsat: order.clientBalanceSat * 1000, - inboundCapacityMsat: order.lspBalanceSat * 1000, - confirmationsRequired: nil, - confirmations: 0, - isOutbound: false, - isChannelReady: false, - isUsable: false, - isAnnounced: false, - cltvExpiryDelta: 144, - counterpartyUnspendablePunishmentReserve: 1000, - counterpartyOutboundHtlcMinimumMsat: 1000, - counterpartyOutboundHtlcMaximumMsat: 99_000_000, - counterpartyForwardingInfoFeeBaseMsat: 1000, - counterpartyForwardingInfoFeeProportionalMillionths: 100, - counterpartyForwardingInfoCltvExpiryDelta: 144, - nextOutboundHtlcLimitMsat: order.clientBalanceSat * 1000, - nextOutboundHtlcMinimumMsat: 1000, - forceCloseSpendDelay: nil, - inboundHtlcMinimumMsat: 1000, - inboundHtlcMaximumMsat: order.lspBalanceSat * 1000, - config: .init( - forwardingFeeProportionalMillionths: 0, - forwardingFeeBaseMsat: 0, - cltvExpiryDelta: 0, - maxDustHtlcExposure: .feeRateMultiplier(multiplier: 0), - forceCloseAvoidanceMaxFeeSatoshis: 0, - acceptUnderpayingHtlcs: true - ) - ) - } - - private var openChannels: [ChannelDetails] { - guard let channels = wallet.channels else { return [] } - return channels.filter(\.isChannelReady) - } - - // MARK: - Helper Methods - private func refreshData() async { guard !isRefreshing else { return } isRefreshing = true @@ -351,12 +294,10 @@ struct LightningConnectionsView: View { } } - isRefreshing = false - } + // Reload after refresh + await loadData() - private func findLinkedOrder(for channel: ChannelDetails) -> IBtOrder? { - guard let orders = blocktank.orders else { return nil } - return channel.findLinkedOrder(in: orders) + isRefreshing = false } private func formatNumber(_ number: UInt64) -> String { diff --git a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift index 017a4449..cf3bcafa 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityItemView.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityItemView.swift @@ -11,6 +11,7 @@ struct ActivityItemView: View { @EnvironmentObject var sheets: SheetViewModel @EnvironmentObject var wallet: WalletViewModel @EnvironmentObject var blocktank: BlocktankViewModel + @EnvironmentObject var channelDetails: ChannelDetailsViewModel @StateObject private var viewModel: ActivityItemViewModel init(item: Activity) { @@ -91,31 +92,6 @@ struct ActivityItemView: View { return activity.channelId } - @State private var closedChannels: [ClosedChannelDetails] = [] - @State private var foundChannel: ChannelDisplayable? = nil - - private var transferChannel: ChannelDisplayable? { - // Return cached found channel if available - if let found = foundChannel { - return found - } - - // Quick synchronous check for open channels (before async task completes) - guard let channelId = transferChannelId, - let channels = wallet.channels, - let openChannel = channels.first(where: { $0.channelId.description == channelId }) - else { - return nil - } - return openChannel - } - - private var findLinkedOrder: IBtOrder? { - guard let channel = transferChannel as? ChannelDetails, - let orders = blocktank.orders else { return nil } - return channel.findLinkedOrder(in: orders) - } - private var navigationTitle: String { if isTransfer { return isTransferToSpending @@ -215,26 +191,9 @@ struct ActivityItemView: View { } } .task { - // Load closed channels if this is a transfer (to find the channel) + // Load channel if this is a transfer guard isTransfer, let channelId = transferChannelId else { return } - - // First check if channel is already in open channels - if let channels = wallet.channels, - let openChannel = channels.first(where: { $0.channelId.description == channelId }) - { - foundChannel = openChannel - return - } - - // Load closed channels - do { - closedChannels = try await CoreService.shared.activity.closedChannels() - if let closedChannel = closedChannels.first(where: { $0.channelId == channelId }) { - foundChannel = closedChannel - } - } catch { - Logger.warn("Failed to load closed channels: \(error)") - } + await channelDetails.findChannel(channelId: channelId, wallet: wallet) } } @@ -365,13 +324,8 @@ struct ActivityItemView: View { } .frame(maxWidth: .infinity, alignment: .leading) - if let fee = activity.fee { + if let feeAmount = viewModel.calculateFeeAmount(linkedOrder: channelDetails.linkedOrder) { let feeLabel = isTransferFromSpending ? t("wallet__activity_fee_prepaid") : t("wallet__activity_fee") - let feeAmount: UInt64 = if isTransferToSpending, let order = findLinkedOrder { - order.serviceFeeSat + order.networkFeeSat - } else { - fee - } VStack(alignment: .leading, spacing: 0) { CaptionMText(feeLabel) @@ -486,18 +440,15 @@ struct ActivityItemView: View { } .accessibilityIdentifier(boostButtonIdentifier) - if isTransfer, let channel = transferChannel { + if isTransfer, let channelId = transferChannelId { CustomButton( title: t("lightning__connection"), size: .small, icon: Image("bolt-hollow") .foregroundColor(accentColor), - shouldExpand: true, - destination: LightningConnectionDetailView( - channel: channel, - linkedOrder: findLinkedOrder, - title: t("lightning__connection") - ) - ) + shouldExpand: true + ) { + navigation.navigate(.connectionDetail(channelId: channelId)) + } .accessibilityIdentifier("ChannelButton") } else { CustomButton(