diff --git a/Bitkit/Services/CoreService.swift b/Bitkit/Services/CoreService.swift index 5cf85f00..3024f43a 100644 --- a/Bitkit/Services/CoreService.swift +++ b/Bitkit/Services/CoreService.swift @@ -145,12 +145,20 @@ class ActivityService { }() let preservedIsBoosted = existingOnchain?.isBoosted ?? false let preservedBoostTxIds = existingOnchain?.boostTxIds ?? [] - let preservedIsTransfer = existingOnchain?.isTransfer ?? false - let preservedChannelId = existingOnchain?.channelId + var preservedIsTransfer = existingOnchain?.isTransfer ?? false + var preservedChannelId = existingOnchain?.channelId let preservedTransferTxId = existingOnchain?.transferTxId 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) { + preservedChannelId = channelId + preservedIsTransfer = true + } + } + // Check if this is a replacement transaction (RBF) that should be marked as boosted let isReplacementTransaction = ActivityService.replacementTransactions.keys.contains(txid) let shouldMarkAsBoosted = preservedIsBoosted || isReplacementTransaction @@ -288,6 +296,34 @@ class ActivityService { } } + /// Check if a transaction spends a closed channel's funding UTXO + private func findClosedChannelForTransaction(txid: String) async -> String? { + do { + let closedChannels = try await getAllClosedChannels(sortDirection: .desc) + guard !closedChannels.isEmpty else { return nil } + + let txDetails = try await AddressChecker.getTransaction(txid: txid) + + // Check if any input spends a closed channel's funding UTXO + for input in txDetails.vin { + guard let inputTxid = input.txid, let inputVout = input.vout else { continue } + + if let matchingChannel = closedChannels.first(where: { channel in + channel.fundingTxoTxid == inputTxid && channel.fundingTxoIndex == UInt32(inputVout) + }) { + return matchingChannel.channelId + } + } + } catch { + Logger.warn( + "Failed to check if transaction \(txid) spends closed channel funding UTXO: \(error)", + context: "CoreService.findClosedChannelForTransaction" + ) + } + + 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/Services/LightningService.swift b/Bitkit/Services/LightningService.swift index baa1049c..88c9c3e0 100644 --- a/Bitkit/Services/LightningService.swift +++ b/Bitkit/Services/LightningService.swift @@ -152,8 +152,10 @@ class LightningService { } await MainActor.run { - channelCache = Dictionary(uniqueKeysWithValues: (channels ?? []).map { ($0.channelId.description, $0) }) - Logger.debug("Refreshed channel cache: \(channelCache.count) channels", context: "LightningService") + let newChannels = Dictionary(uniqueKeysWithValues: (channels ?? []).map { ($0.channelId.description, $0) }) + for (key, value) in newChannels { + channelCache[key] = value + } } } @@ -602,8 +604,14 @@ extension LightningService { if let channel { await registerClosedChannel(channel: channel, reason: reasonString) + await MainActor.run { + channelCache.removeValue(forKey: channelIdString) + } } else { - Logger.error("Could not find channel details for closed channel: \(userChannelId) in cache", context: "LightningService") + Logger.error( + "Could not find channel details for closed channel: channelId=\(channelIdString) userChannelId=\(userChannelId) in cache", + context: "LightningService" + ) } case .paymentForwarded: break diff --git a/Bitkit/Views/Wallets/Activity/ActivityIcon.swift b/Bitkit/Views/Wallets/Activity/ActivityIcon.swift index 589a3a23..d1c590d9 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityIcon.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityIcon.swift @@ -64,10 +64,16 @@ struct ActivityIcon: View { ) } else { let paymentIcon = txType == PaymentType.sent ? "arrow-up" : "arrow-down" + let (iconColor, backgroundColor): (Color, Color) = if isTransfer { + // From savings (to spending) = sent = orange, From spending (to savings) = received = purple + txType == .sent ? (.brandAccent, .brand16) : (.purpleAccent, .purple16) + } else { + (.brandAccent, .brand16) + } CircularIcon( icon: isTransfer ? "arrow-up-down" : paymentIcon, - iconColor: .brandAccent, - backgroundColor: .brand16, + iconColor: iconColor, + backgroundColor: backgroundColor, size: size ) } diff --git a/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift b/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift index d30c881e..62f9dcf4 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift @@ -39,23 +39,31 @@ struct ActivityRowOnchain: View { return DateFormatterHelpers.getActivityItemDate(item.timestamp) } + private var feeDescription: String { + TransactionSpeed.getFeeDescription(feeRate: item.feeRate, feeEstimates: feeEstimates) + } + + private var durationWithoutSymbol: String { + // Remove ± symbol since localization strings already include it + feeDescription.replacingOccurrences(of: "±", with: "") + } + private var description: String { 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"]) + t("wallet__activity_transfer_spending_pending", variables: ["duration": durationWithoutSymbol]) case .received: return item.confirmed ? t("wallet__activity_transfer_savings_done") : - t("wallet__activity_transfer_savings_pending", variables: ["duration": "TODO"]) + t("wallet__activity_transfer_savings_pending", variables: ["duration": durationWithoutSymbol]) } } else { if item.confirmed { return formattedTime } else { - let feeDescription = TransactionSpeed.getFeeDescription(feeRate: item.feeRate, feeEstimates: feeEstimates) return t("wallet__activity_confirms_in", variables: ["feeRateDescription": feeDescription]) } }