Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -88,6 +89,7 @@ struct AppScene: View {
.environmentObject(suggestionsManager)
.environmentObject(tagManager)
.environmentObject(transferTracking)
.environmentObject(channelDetails)
.onAppear {
if !settings.pinEnabled {
isPinVerified = true
Expand Down
1 change: 1 addition & 0 deletions Bitkit/Extensions/ChannelDetails+Extensions.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import BitkitCore
import Foundation
import LDKNode

Expand Down
1 change: 1 addition & 0 deletions Bitkit/MainNavView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1007,6 +1009,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}";
Expand All @@ -1023,6 +1026,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";
Expand Down
62 changes: 59 additions & 3 deletions Bitkit/Services/CoreService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@

var isConfirmed = false
var confirmedTimestamp: UInt64?
if case let .confirmed(blockHash, height, blockTimestamp) = txStatus {

Check warning on line 130 in Bitkit/Services/CoreService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

immutable value 'height' was never used; consider replacing with '_' or removing it

Check warning on line 130 in Bitkit/Services/CoreService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

immutable value 'blockHash' was never used; consider replacing with '_' or removing it
isConfirmed = true
confirmedTimestamp = blockTimestamp
}
Expand All @@ -151,9 +151,11 @@
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 = await self.findChannelForTransaction(txid: txid, direction: payment.direction)

if let channelId {
preservedChannelId = channelId
preservedIsTransfer = true
}
Expand Down Expand Up @@ -239,7 +241,7 @@
print(payment)
addedCount += 1
}
} else if case let .bolt11(hash, preimage, secret, description, bolt11) = payment.kind {

Check warning on line 244 in Bitkit/Services/CoreService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

immutable value 'secret' was never used; consider replacing with '_' or removing it

Check warning on line 244 in Bitkit/Services/CoreService.swift

View workflow job for this annotation

GitHub Actions / Run Tests

immutable value 'hash' was never used; consider replacing with '_' or removing it
// Skip pending inbound payments, just means they created an invoice
guard !(payment.status == .pending && payment.direction == .inbound) else { continue }

Expand Down Expand Up @@ -296,6 +298,18 @@
}
}

/// 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 {
Expand Down Expand Up @@ -324,6 +338,48 @@
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 {
Expand Down
15 changes: 15 additions & 0 deletions Bitkit/ViewModels/ActivityItemViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
188 changes: 188 additions & 0 deletions Bitkit/ViewModels/ChannelDetailsViewModel.swift
Original file line number Diff line number Diff line change
@@ -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
)
)
}
}
1 change: 1 addition & 0 deletions Bitkit/ViewModels/NavigationViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading